diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4845e95..2e5d45b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,12 @@ jobs: - name: Type check all packages run: yarn typecheck + # Fails when a public name changes in either entry point of + # @launchdarkly/ai-server without the checked-in report under + # packages/client/etc/ being updated (yarn workspace @launchdarkly/ai-server api:update). + - name: Check client API reports + run: yarn workspace @launchdarkly/ai-server api:check + # ─── Test ─────────────────────────────────────────────────────────────────── test: name: Tests diff --git a/.gitignore b/.gitignore index 7065b80a..3527f6e7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,6 @@ spec.md tmp/ integration-tests/**/.staging/ -.claude/ \ No newline at end of file +.claude/ +# API Extractor working files +packages/client/temp/ diff --git a/AGENTS.md b/AGENTS.md index 12f21497..dc081f74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,11 +164,11 @@ The client manages a singleton connection to LaunchDarkly and the associated tel | Export | Description | |---|---| | `initClient(options?)` | Auto-discovers and initializes `@launchdarkly/node-server-sdk` (optional peer dep, loaded via dynamic import). Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Accepts optional overrides for SDK key, base URIs, service name, environment, and OTLP endpoint. Returns `Promise`. On every successful path, including the already-initialized path, flushes `$ld:ai:sdk:info` for any LaunchDarkly AI packages that have not yet reported. | -| `initClient(client)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface` (e.g. from `@launchdarkly/vercel-server-sdk`). Stores it directly without calling the node SDK. Flushes pending `$ld:ai:sdk:info` events. | +| `initClient(client, options?)` | **BYOC overload** — accepts a pre-initialized `LDClientInterface` (e.g. from `@launchdarkly/vercel-server-sdk`). Stores it directly without calling the node SDK. Flushes pending `$ld:ai:sdk:info` events. The optional second argument is the same options bag as the other overload. Idempotent like that overload: once a client is set, a second call returns it and ignores both a different client and the new telemetry options — `shutdown()` first to swap clients. | | `getClient()` | Returns the initialized `LDClientInterface`. Throws if initialization has not completed. | | `shutdown()` | Flushes all pending events and telemetry, then closes the client. Must be called before the process exits. Clears sdk-info reporting so a later client reports again. | | `waitForTelemetry()` | Waits for the OTel provider to be ready. Useful when spans must not be dropped at startup. | -| `shutdownTelemetry()` | Flushes and stops the OTel exporter independently of the LD client. | +| `shutdownTelemetry()` | Flushes and stops the OTel exporter independently of the LD client, and releases the OTel globals the SDK registered. | | `registerAiSdkPackage(name, version)` | Records a LaunchDarkly AI package identity. Handler and convenience packages call this at import time. | ### Core Data Types diff --git a/integration-tests/commonjs-webpack/commonjs-webpack.test.ts b/integration-tests/commonjs-webpack/commonjs-webpack.test.ts index 84c0681b..d87c9200 100644 --- a/integration-tests/commonjs-webpack/commonjs-webpack.test.ts +++ b/integration-tests/commonjs-webpack/commonjs-webpack.test.ts @@ -1,7 +1,18 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { beforeAll, describe, expect, it } from 'vitest'; -import { bundle, createConsumer, invoke, packedFiles, repoRoot, runEntrypoint } from './harness.js'; +import { + bundle, + consumerDir, + createConsumer, + invoke, + packedFiles, + RESOLUTION_MODES, + type ResolutionMode, + repoRoot, + runEntrypoint, + typecheck, +} from './harness.js'; const SETUP_TIMEOUT_MS = 15 * 60 * 1000; const CASE_TIMEOUT_MS = 5 * 60 * 1000; @@ -37,7 +48,12 @@ const OPTIONAL_DEPS: Record = { const RESOLVED_SYMBOLS = { aiNodeConfig: 'function', aiNodeInitClient: 'function', + aiNodeExperimentalGetSkill: 'function', + aiNodeRootGetSkill: 'undefined', aiServerConfig: 'function', + // The experimental subpath resolves, and its names stay off the root. + aiServerExperimentalGetSkill: 'function', + aiServerRootGetSkill: 'undefined', openaiMessages: 'function', langchainMessages: 'function', }; @@ -109,6 +125,31 @@ describe('CommonJS consumer bundled with Webpack + webpack-node-externals', () = CASE_TIMEOUT_MS, ); + // `node10` ignores `exports`, so the experimental subpath's types resolve only through + // `typesVersions`; without it the import fails with TS2307. + it.each(Object.keys(RESOLUTION_MODES) as ResolutionMode[])( + 'type-checks root and experimental imports under moduleResolution %s', + (mode) => { + const result = typecheck(mode); + + expect(result.stderr).toBe(''); + expect(result.status, result.stdout).toBe(0); + // A module tsc cannot resolve in the consumer is looked up again in every ancestor + // `node_modules`, and the consumer sits inside this repo, so a failed lookup would land on + // the workspace copy and pass. Every file loaded from the repo must be the consumer's own. + const loaded = result.stdout.split('\n').filter((file) => file.startsWith(repoRoot)); + expect(loaded.filter((file) => !file.startsWith(`${consumerDir}/`))).toEqual([]); + for (const pkg of ['ai-server', 'ai-node']) { + const experimental = join(consumerDir, 'node_modules', '@launchdarkly', pkg, 'dist', 'experimental.d.'); + expect( + loaded.some((file) => file.startsWith(experimental)), + pkg, + ).toBe(true); + } + }, + CASE_TIMEOUT_MS, + ); + it.each([...Object.keys(OPTIONAL_DEPS)])('keeps %s optional dependencies behind a dynamic import in CJS', (pkg) => { const cjs = readFileSync(join(repoRoot, 'packages', pkg, 'dist', 'index.cjs'), 'utf8'); @@ -129,6 +170,20 @@ describe('CommonJS consumer bundled with Webpack + webpack-node-externals', () = expect.arrayContaining(['dist/index.js', 'dist/index.cjs', 'dist/index.d.ts', 'dist/index.d.cts']), ); }); + + it.each([ + 'client', + 'ai-node', + ])('packs the experimental entry point in both runtime and declaration formats for %s', (workspace) => { + expect(packedFiles.get(workspace)).toEqual( + expect.arrayContaining([ + 'dist/experimental.js', + 'dist/experimental.cjs', + 'dist/experimental.d.ts', + 'dist/experimental.d.cts', + ]), + ); + }); }); describe('package manifests', () => { @@ -143,4 +198,13 @@ describe('package manifests', () => { expect(manifest.module).toBe('dist/index.js'); expect(manifest.types).toBe('dist/index.d.ts'); }); + + it.each(['client', 'ai-node'])('%s declares the experimental subpath with both conditions', (pkg) => { + const manifest = JSON.parse(readFileSync(join(repoRoot, 'packages', pkg, 'package.json'), 'utf8')); + + expect(manifest.exports['./experimental']).toEqual({ + import: { types: './dist/experimental.d.ts', default: './dist/experimental.js' }, + require: { types: './dist/experimental.d.cts', default: './dist/experimental.cjs' }, + }); + }); }); diff --git a/integration-tests/commonjs-webpack/fixture/entrypoints.cjs b/integration-tests/commonjs-webpack/fixture/entrypoints.cjs index a53d6b38..10009d70 100644 --- a/integration-tests/commonjs-webpack/fixture/entrypoints.cjs +++ b/integration-tests/commonjs-webpack/fixture/entrypoints.cjs @@ -2,7 +2,9 @@ // Loads every installed package root through `require` and reports what resolved. const aiNode = require('@launchdarkly/ai-node'); +const aiNodeExperimental = require('@launchdarkly/ai-node/experimental'); const aiServer = require('@launchdarkly/ai-server'); +const aiServerExperimental = require('@launchdarkly/ai-server/experimental'); const openaiMessages = require('@launchdarkly/ai-openai-messages'); const langchainMessages = require('@launchdarkly/ai-langchain-messages'); @@ -12,7 +14,11 @@ process.stdout.write( JSON.stringify({ aiNodeConfig: typeof aiNode.config, aiNodeInitClient: typeof aiNode.initClient, + aiNodeExperimentalGetSkill: typeof aiNodeExperimental.getSkill, + aiNodeRootGetSkill: typeof aiNode.getSkill, aiServerConfig: typeof aiServer.config, + aiServerExperimentalGetSkill: typeof aiServerExperimental.getSkill, + aiServerRootGetSkill: typeof aiServer.getSkill, openaiMessages: typeof openaiMessages.openaiMessages, langchainMessages: typeof langchainMessages.langchainMessages, }), diff --git a/integration-tests/commonjs-webpack/fixture/entrypoints.mjs b/integration-tests/commonjs-webpack/fixture/entrypoints.mjs index d5c28907..c5543107 100644 --- a/integration-tests/commonjs-webpack/fixture/entrypoints.mjs +++ b/integration-tests/commonjs-webpack/fixture/entrypoints.mjs @@ -1,8 +1,10 @@ // ESM twin of entrypoints.cjs — same package roots, resolved through the `import` condition. import * as aiNode from '@launchdarkly/ai-node'; +import * as aiNodeExperimental from '@launchdarkly/ai-node/experimental'; import * as langchainMessages from '@launchdarkly/ai-langchain-messages'; import * as openaiMessages from '@launchdarkly/ai-openai-messages'; import * as aiServer from '@launchdarkly/ai-server'; +import * as aiServerExperimental from '@launchdarkly/ai-server/experimental'; await import('@launchdarkly/ai-otel'); @@ -10,7 +12,11 @@ process.stdout.write( JSON.stringify({ aiNodeConfig: typeof aiNode.config, aiNodeInitClient: typeof aiNode.initClient, + aiNodeExperimentalGetSkill: typeof aiNodeExperimental.getSkill, + aiNodeRootGetSkill: typeof aiNode.getSkill, aiServerConfig: typeof aiServer.config, + aiServerExperimentalGetSkill: typeof aiServerExperimental.getSkill, + aiServerRootGetSkill: typeof aiServer.getSkill, openaiMessages: typeof openaiMessages.openaiMessages, langchainMessages: typeof langchainMessages.langchainMessages, }), diff --git a/integration-tests/commonjs-webpack/fixture/types.ts b/integration-tests/commonjs-webpack/fixture/types.ts new file mode 100644 index 00000000..96011eae --- /dev/null +++ b/integration-tests/commonjs-webpack/fixture/types.ts @@ -0,0 +1,15 @@ +// Type-checked by the integration test under each `moduleResolution` a consumer may use, +// including `node` (node10), which ignores `exports` and needs `typesVersions` for a subpath. +import { config, initClient } from '@launchdarkly/ai-node'; +import { setSkillStore as setNodeSkillStore } from '@launchdarkly/ai-node/experimental'; +import * as server from '@launchdarkly/ai-server'; +import { InMemorySkillStore, type SkillStore, setSkillStore } from '@launchdarkly/ai-server/experimental'; + +const store: SkillStore = new InMemorySkillStore(); +setSkillStore(store); +setNodeSkillStore(store); + +// @ts-expect-error Agent Skills is not exported from the package root. +export const rootGetSkill = server.getSkill; + +export const used = [config, initClient, server.config]; diff --git a/integration-tests/commonjs-webpack/harness.ts b/integration-tests/commonjs-webpack/harness.ts index 3a529e3c..0018159a 100644 --- a/integration-tests/commonjs-webpack/harness.ts +++ b/integration-tests/commonjs-webpack/harness.ts @@ -17,6 +17,9 @@ const WORKSPACES = ['client', 'ai-node', 'ai-otel', 'openai-messages', 'langchai /** Pinned to the versions reported in AIC-3370. */ const BUNDLER_DEPS = ['webpack@5.104.1', 'webpack-cli@6.0.1', 'webpack-node-externals@3.0.0']; +/** Type-checks the consumer's imports (see `typecheck`). */ +const TYPESCRIPT_DEP = 'typescript@5.9.3'; + export type Variant = 'externalized' | 'allowlisted'; /** Workspace name -> paths inside the tarball `npm publish` would upload. */ @@ -92,7 +95,7 @@ export function createConsumer(): string { ); cpSync(join(here, 'fixture'), consumerDir, { recursive: true }); - runOrThrow('npm', ['install', '--no-audit', '--no-fund', ...tarballs, ...BUNDLER_DEPS], consumerDir); + runOrThrow('npm', ['install', '--no-audit', '--no-fund', ...tarballs, ...BUNDLER_DEPS, TYPESCRIPT_DEP], consumerDir); return consumerDir; } @@ -107,6 +110,42 @@ export function invoke(variant: Variant): RunResult { return run('node', ['invoke.cjs', `./dist-${variant}/handler.js`], consumerDir); } +/** `moduleResolution` modes paired with the `module` setting each requires. */ +export const RESOLUTION_MODES = { + node10: 'commonjs', + node16: 'node16', + bundler: 'esnext', +} as const; + +export type ResolutionMode = keyof typeof RESOLUTION_MODES; + +/** + * Type-checks `types.ts` against the installed tarballs' declarations. Runs the consumer's own + * `tsc` rather than `npx`, whose npm config warnings would land in stderr. `--listFiles` prints + * every file the program loaded, so a caller can check where each declaration came from. + */ +export function typecheck(mode: ResolutionMode): RunResult { + return run( + 'node', + [ + join('node_modules', 'typescript', 'bin', 'tsc'), + '--noEmit', + '--listFiles', + // Only the consumer's own `@types`, not those of every ancestor `node_modules`. + '--typeRoots', + join('node_modules', '@types'), + '--strict', + '--skipLibCheck', + '--module', + RESOLUTION_MODES[mode], + '--moduleResolution', + mode, + 'types.ts', + ], + consumerDir, + ); +} + /** Runs an unbundled entry point against the installed tarballs. */ export function runEntrypoint(file: string): RunResult { return run('node', [file], consumerDir); diff --git a/packages/ai-node/README.md b/packages/ai-node/README.md index 0a3a3b2a..2fa7a2e9 100644 --- a/packages/ai-node/README.md +++ b/packages/ai-node/README.md @@ -111,6 +111,19 @@ module.exports = { }; ``` +## Experimental features + +Experimental features are not exported from the package root. Import them from +`@launchdarkly/ai-node/experimental`, which re-exports `@launchdarkly/ai-server/experimental`, so +you need no direct dependency on `@launchdarkly/ai-server`. For example: + +```ts +import { InMemorySkillStore, setSkillStore } from '@launchdarkly/ai-node/experimental'; +``` + +Names there may change in a minor release. See the +[Agent Skills guide](../client/README.md#agent-skills-experimental). + ## Full API reference See [`@launchdarkly/ai-server`](../client/README.md) — all exports are re-exported unchanged from this package. diff --git a/packages/ai-node/agents.md b/packages/ai-node/agents.md index 8c039bde..eed4c257 100644 --- a/packages/ai-node/agents.md +++ b/packages/ai-node/agents.md @@ -21,6 +21,7 @@ For edge runtimes (Vercel, Cloudflare Workers, Deno, etc.) use `@launchdarkly/ai | File | Responsibility | |---|---| | `src/index.ts` | Single `export * from '@launchdarkly/ai-server'` — the entire public barrel | +| `src/experimental.ts` | Single `export * from '@launchdarkly/ai-server/experimental'` — published as `@launchdarkly/ai-node/experimental`, so Node.js users reach experimental features (Agent Skills) without a direct `@launchdarkly/ai-server` dependency | --- @@ -32,7 +33,7 @@ This package re-exports everything from `@launchdarkly/ai-server` and nothing el export * from '@launchdarkly/ai-server'; ``` -Every symbol available from `@launchdarkly/ai-server` is available from `@launchdarkly/ai-node` under the same name. No additional symbols are added. When `@launchdarkly/ai-server` gains a new export, this package automatically picks it up. +Every symbol available from `@launchdarkly/ai-server` is available from `@launchdarkly/ai-node` under the same name, and every symbol from `@launchdarkly/ai-server/experimental` is available from `@launchdarkly/ai-node/experimental`. No additional symbols are added. When `@launchdarkly/ai-server` gains a new export, this package automatically picks it up. --- diff --git a/packages/ai-node/package.json b/packages/ai-node/package.json index 0efabf4d..f76528c6 100644 --- a/packages/ai-node/package.json +++ b/packages/ai-node/package.json @@ -27,6 +27,13 @@ "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", + "typesVersions": { + "*": { + "experimental": [ + "dist/experimental.d.ts" + ] + } + }, "exports": { ".": { "import": { @@ -37,6 +44,16 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./experimental": { + "import": { + "types": "./dist/experimental.d.ts", + "default": "./dist/experimental.js" + }, + "require": { + "types": "./dist/experimental.d.cts", + "default": "./dist/experimental.cjs" + } } }, "files": [ diff --git a/packages/ai-node/src/__tests__/reexports.test.ts b/packages/ai-node/src/__tests__/reexports.test.ts index b94cfe14..bb64ce47 100644 --- a/packages/ai-node/src/__tests__/reexports.test.ts +++ b/packages/ai-node/src/__tests__/reexports.test.ts @@ -29,3 +29,21 @@ describe('@launchdarkly/ai-node re-exports', () => { } }); }); + +describe('@launchdarkly/ai-node/experimental re-exports', () => { + it('exports every named export from @launchdarkly/ai-server/experimental, as the same references', async () => { + const serverExperimental = await import('@launchdarkly/ai-server/experimental'); + const nodeExperimental = await import('../experimental.js'); + + const serverKeys = Object.keys(serverExperimental); + expect(serverKeys.length).toBeGreaterThan(0); + for (const key of serverKeys) { + expect((nodeExperimental as any)[key], key).toBe((serverExperimental as any)[key]); + } + }); + + it('keeps experimental names off the package root', () => { + expect('getSkill' in node).toBe(false); + expect('setSkillStore' in node).toBe(false); + }); +}); diff --git a/packages/ai-node/src/experimental.ts b/packages/ai-node/src/experimental.ts new file mode 100644 index 00000000..bb7f2818 --- /dev/null +++ b/packages/ai-node/src/experimental.ts @@ -0,0 +1,17 @@ +/** + * Experimental features of the LaunchDarkly AI SDK for Node.js. + * + * @launchdarkly/ai-node/experimental + * + * Re-exports `@launchdarkly/ai-server/experimental`, so a Node.js application + * that installs only this package can reach experimental features without a + * direct dependency on `@launchdarkly/ai-server` (required under strict + * package managers such as pnpm and Yarn PnP). Every name here may change in a + * minor release, and none is exported from the package root. + */ +import { registerAiSdkPackage } from '@launchdarkly/ai-server'; +import { LD_AI_PACKAGE_NAME, LD_AI_PACKAGE_VERSION } from './version.js'; + +registerAiSdkPackage(LD_AI_PACKAGE_NAME, LD_AI_PACKAGE_VERSION); + +export * from '@launchdarkly/ai-server/experimental'; diff --git a/packages/ai-node/tsup.config.ts b/packages/ai-node/tsup.config.ts index 58df271d..8e5dd52e 100644 --- a/packages/ai-node/tsup.config.ts +++ b/packages/ai-node/tsup.config.ts @@ -1 +1,10 @@ -export { default } from '../../tsup.config.base'; +import { defineConfig } from 'tsup'; +import base from '../../tsup.config.base'; + +// Two entry points, mirroring @launchdarkly/ai-server: the package root and +// `@launchdarkly/ai-node/experimental`. Both re-export from external packages, +// so there is nothing to share between them. +export default defineConfig({ + ...base, + entry: { index: 'src/index.ts', experimental: 'src/experimental.ts' }, +}); diff --git a/packages/client/README.md b/packages/client/README.md index 1c6776a3..8a790c27 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -80,11 +80,11 @@ await shutdown(); | Export | Description | |---|---| | `initClient(options?)` | Auto-discover and initialize `@launchdarkly/node-server-sdk`. Optional — the first AI API call triggers lazy init when `LD_SDK_KEY` is set. Returns `Promise`. | -| `initClient(client)` | **BYOC overload** — accept a pre-initialized `LDClientInterface` (e.g. from `@launchdarkly/vercel-server-sdk` or any edge runtime). Skips SDK auto-discovery. | +| `initClient(client, options?)` | **BYOC overload** — accept a pre-initialized `LDClientInterface` (e.g. from `@launchdarkly/vercel-server-sdk` or any edge runtime). Skips SDK auto-discovery. | | `getClient()` | Return the initialized `LDClientInterface`. Throws if `initClient` has not completed. | | `shutdown()` | Flush all events and telemetry, then close the client. Call before process exit. | | `waitForTelemetry()` | Wait for the OTel provider to be ready. Useful to avoid dropping early spans. | -| `shutdownTelemetry()` | Flush and stop the OTel exporter independently of the LD client. | +| `shutdownTelemetry()` | Flush and stop the OTel exporter independently of the LD client, and release the OTel globals the SDK registered. | | `inspectConfig(key, context)` | Read an AI Config variation without invoking the model. Never throws. Returns `{ enabled, config, meta }`. | ### `config(args)` @@ -233,6 +233,360 @@ if (!result.enabled) { --- +### Agent Skills (experimental) + +> **Experimental.** Agent Skills is published from the `@launchdarkly/ai-server/experimental` entry point and is not exported from the package root. Its names may change in a minor release, so pin the version you test against and read the changelog before upgrading. Import every name in this section from `@launchdarkly/ai-server/experimental` (or `@launchdarkly/ai-node/experimental` if you install `@launchdarkly/ai-node`). + +Agent Skills are versioned `SKILL.md` documents managed in LaunchDarkly and attached to AI Config variations by reference. This package tells you which skills a config references, retrieves their content, and writes them to `//SKILL.md`, where agent runtimes such as the Claude Agent SDK discover them. + +**Everything below goes through a `SkillStore`.** None is configured by default, so the accessors throw an actionable error until you set one. Use `InMemorySkillStore` for local development, tests, and bring-your-own-content, and `FDv2SkillStore` to receive content from LaunchDarkly — see [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly). + +```ts +import { createHash } from 'node:crypto'; +import { + allSkills, + getSkill, + getSkillResult, + getSkills, + InMemorySkillStore, + setSkillStore, + skillRefs, + writeSkills, +} from '@launchdarkly/ai-server/experimental'; + +// A store serves wire-shaped raw objects. `contentHash` is sha256, lowercase +// hex, over the verbatim UTF-8 bytes of `content`. Content that does not hash +// to it is withheld. +const content = '---\nname: PDF Extraction\n---\nExtract text from PDFs.\n'; +const store = new InMemorySkillStore(); +store.put({ + key: 'pdf-extraction', + version: 2, + content, + contentHash: createHash('sha256').update(Buffer.from(content, 'utf-8')).digest('hex'), +}); + +// Configure it. This is independent of initClient: it can run before or after +// the client is initialized, on any runtime. A later call replaces the store; +// a nullish argument is ignored. shutdown() clears it. +setSkillStore(store); + +// Which skills does a resolved config reference? A pure projection — no I/O, +// and it works before any client exists. +const refs = skillRefs({ + model: { name: 'claude-opus-4-5' }, + provider: { name: 'Anthropic' }, + instructions: 'Summarize the attached document.', + skills: [{ key: 'pdf-extraction', version: 2 }], +}); // [{ key: 'pdf-extraction', version: 2 }] + +// In real use the config comes from LaunchDarkly. inspectConfig answers +// `config: null` when the config could not be resolved, and skillRefs throws a +// TypeError for it rather than returning [], which writeSkills would read as +// "prune every skill it manages". Check first to handle it without the throw: +// const info = await inspectConfig('doc-agent', { kind: 'user', key: 'user-123' }); +// if (!info.config) return; // unresolved: leave the skills on disk as they are +// const refs = skillRefs(info.config); + +// Retrieve content. Every skill is hash-verified before you see it. +// `Skill.content` is a Uint8Array of the verified verbatim bytes; the SDK never +// interprets them. +const newest = await getSkill('pdf-extraction'); // newest available +const pinned = await getSkill('pdf-extraction', { version: 2 }); // exact version, or null +const batch = await getSkills(refs); // input order; misses omitted +// `getSkill` returns null for four different reasons. To tell them apart (for +// example, to fail closed on suspected tampering), ask for the outcome instead. +const outcome = await getSkillResult('pdf-extraction', { version: 2 }); +if (outcome.reason === 'integrity_failure') throw new Error(outcome.detail ?? 'withheld'); +const everything = await allSkills(); +const text = new TextDecoder().decode(newest?.content); // if you want a string + +// Materialize onto disk at //SKILL.md. Only the leaf directory is +// created, so `.claude` must already exist. +const report = await writeSkills(refs, '.claude/skills'); +if (!report.ok) { + for (const action of report.errors) { + console.error(`skill ${action.key}: ${action.error}`); + } +} +``` + +`writeSkills` is a **reconcile**, not a copy. It records what it wrote in `/.launchdarkly-skills.json` and overwrites or deletes **only** paths that manifest lists under a matching key. A file you placed yourself is reported as an error and left alone. Revocation is pruning: a skill absent from the resolved set is removed on the next run. + +**One exception: byte-identical files are adopted.** A file at a managed path whose bytes already equal the resolved content is recorded in the manifest and reported `skipped_current` instead of refused. This lets a reconcile that crashed after writing a skill file, but before writing the manifest, recover on the next run. A file whose bytes differ, or that cannot be read, is still refused and left untouched. + +```ts +// Everything the store holds, materialized at boot. +const report = await writeSkills('*', '.claude/skills', { + prune: true, // default — remove formerly-managed skills no longer resolved + timeout: 10, // seconds, not milliseconds + onUnavailable: 'keep', // 'keep' reports a failed retrieval; 'raise' throws +}); +``` + +**Know what `'*'` asks for.** It writes the **whole project library**, which puts every skill's `description` into the agent's context, including skills no AI Config references and skills belonging to other teams. `writeSkills(skillRefs(config), root)`, as in the first example, writes only what the resolved variation asked for. + +| Export | Description | +|---|---| +| `setSkillStore(store)` | Set the `SkillStore` the accessors, `writeSkills`, and `watchSkills` read from. Applies on every call; a nullish argument never clears the configured store, and `shutdown()` does. Throws `TypeError` for anything else without `getObject` and `allObjects` methods. Replacing a store does not close the old one, and a running watcher keeps the store it started with. Not an `initClient` option. | +| `skillRefs(config)` | Project a config's `skills` array into typed `SkillReference[]`. Pure — no client, no store, no telemetry. `[]` when the field is absent. Throws `TypeError` when `config` is not an object, `null` and `undefined` included, since that is what `inspectConfig` answers for a config it could not resolve, and when the field is present but malformed (including `null`, or one bad entry). Either way `writeSkills` never receives an empty or partial list that would prune skills the config still references. `parseAiConfig` does not check `skills`, so a malformed field never fails `config().invoke()` or other core calls. | +| `getSkill(key, { version? })` | One verified skill. Omit `version` for the newest available. Resolves to `null` when the skill is unavailable; throws only when no store is configured. | +| `getSkillResult(key, { version? })` | The same retrieval, reporting **why**: resolves to `{ skill, reason, detail }`, where `reason` is `ok` / `absent` / `integrity_failure` / `store_unavailable` / `wrong_version`. Throws only when no store is configured. See [fail closed on tampering](#fail-closed-on-tampering-getskillresult). | +| `getSkills(refs)` | Batch form. Accepts `SkillReference` values and bare key strings (string = latest). Results follow input order; missing or unverifiable entries are omitted, and a warning logs how many failed verification. | +| `allSkills()` | Every verified skill the store holds, newest version per key. | +| `writeSkills(skills, root, options?)` | Materialize to `//SKILL.md`. Accepts `Skill` / `SkillReference` / key strings, or the literal `'*'`. Returns a `ReconcileReport`. Throws for a caller error (an unusable `root`, a bare string other than `'*'`), distinct from the per-skill `error` actions in the report. | +| `InMemorySkillStore` | An in-memory `SkillStore` for local development and testing: `put(raw)`, `getObject(kind, key, version?)`, `allObjects(kind)`, `addListener(kind, fn)`, `removeListener(kind, fn)`. Holds several versions of a key: `getObject` answers a pin with exactly that version and an omitted version with the newest. `addListener` throws for any kind but `'skill'`. | +| `FDv2SkillStore(sdkKey, options?)` | The delivery transport: a `SkillStore` fed by LaunchDarkly over the SDK-facing FDv2 channel. `start()`, `waitForSkills({ timeoutSeconds })`, `isInitialized()`, `close()`, `diagnostics`, `failed`, `addListener` / `removeListener`. `close()` is **final** — `start()` throws afterwards. Options (`FDv2SkillStoreOptions`): `mode` (`'stream'` default, or `'poll'`), `baseUri`, `streamUri`, `pollIntervalSeconds`, `readTimeoutSeconds`, `initialBackoffSeconds`, `maxBackoffSeconds` (all in seconds, each positive and finite, with `initialBackoffSeconds` no greater than `maxBackoffSeconds`; the constructor throws otherwise). **Server-side only.** See [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly). | +| `watchSkills(skills, root, options?)` | `writeSkills` plus a re-reconcile on every delivery change, so with `'*'` revocation takes effect within `debounceMs` rather than at the next restart (see [Receiving skills from LaunchDarkly](#receiving-skills-from-launchdarkly) for an explicit list). Resolves to `{ report, watcher }`; `await watcher.close()` when done. Options (`WatchSkillsOptions`): everything `writeSkills` takes, plus `debounceMs` (milliseconds, default `SKILLS_DEFAULT_DEBOUNCE_MS`) and `onReconcile`. One watcher per root. | +| `SkillWatcher` | Returned by `watchSkills`: `reconciles` (re-reconciles completed, excluding the initial one), `notify` (the registered change listener), `close()` (idempotent; detaches and awaits any reconcile in flight). | +| `StoreDiagnostics` | What the transport has seen: `payloadsTransferred`, `skillObjectsReceived`, `objectsIgnored`, `objectsRevoked` (keys removed by a `delete-object` or dropped by a full transfer; a version bump or a tombstone for an unknown key does not count), `payloadsIgnored`, `hashlessObjects`, `connectionFailures`, `lastError`. | +| `SKILLS_DEFAULT_BASE_URI` / `SKILLS_DEFAULT_STREAM_URI` | `'https://sdk.launchdarkly.com'` and `'https://stream.launchdarkly.com'`, the default hosts for `GET /sdk/poll` and `GET /sdk/stream`. | +| `SKILLS_DEFAULT_DEBOUNCE_MS` | `500` — the `watchSkills` coalescing window in milliseconds. | +| `createSkill(init)` / `createSkillReference(init)` | Build frozen `Skill` / `SkillReference` values. Use `createSkill` to hand `writeSkills` content you already have. | +| `createSkillOutcome(init)` | Build a frozen `SkillOutcome`, for tests or for wrapping your own retrieval in the same shape. | +| `SKILL_FILENAME` | `'SKILL.md'`. | +| `MANIFEST_FILENAME` | `'.launchdarkly-skills.json'` — add this to your `.gitignore` if you do not commit materialized skills. | +| `MANIFEST_VERSION` | `1`. | + +Two internal constants are **not** exported: + +- `MAX_SKILL_CONTENT_BYTES` (10 MiB) is a local backstop above which content is withheld. The platform enforces its own, lower limit before delivery, so do not pre-flight against this one. When it withholds content, the `over_size_cap` reason string names the bound. +- `SKILL_OBJECT_KIND` (`'skill'`) is the kind this SDK passes to a store. A store adapter maps whatever its transport calls a skill onto it; it is not the wire contract. + +`ReconcileReport` exposes `actions`, `ok` (true when no action is an `error`), and `errors` (the error actions, in order). Each `ReconcileAction` has `key`, `action` (`written` | `updated` | `skipped_current` | `removed` | `error`), and nullable `version` / `path` / `error`. A failure that belongs to the whole run, such as a corrupt manifest, has the **empty string** as its `key`. + +**Security posture.** `writeSkills` fails closed: + +- skill keys are re-validated locally, and content is hash-verified again immediately before writing; +- writes go to a temp file in the target's own directory, then an atomic rename, at mode `0644`; +- symlinked roots, directories, and targets are refused, as is a target that is not a regular file (a FIFO, a device node); +- a corrupt manifest suppresses every destructive action. + +Node exposes no `renameat`/`unlinkat`/`openat`, so how well a directory swap is defended depends on the platform: + +- **On Linux, the swap window is closed.** The managed root is opened once (`O_RDONLY|O_DIRECTORY|O_NOFOLLOW`) and held for the whole reconcile, and every child (each `//`, `SKILL.md`, temp file, and the manifest) is addressed as `/proc/self/fd//`. The kernel resolves that to the inode the descriptor holds, so renaming the root or replacing it with a symlink after validation cannot redirect a write or a delete. +- **On macOS and Windows, the window is narrowed but not closed.** `/dev/fd//` does not resolve, so each step is preceded by a per-component `lstat` — a check-then-use race that an attacker with **write permission on the managed root, or on any of its ancestor directories**, can win to redirect a write or delete outside the root. Windows also has no reparse-point checks (`GetFileAttributesW` / `FILE_FLAG_OPEN_REPARSE_POINT`) in this release and is not a tested platform. + +On macOS and Windows, write permission on the managed root **and its ancestors** is therefore *the* security boundary. Keep them writable only by the identity running the reconcile — see [privilege separation](#privilege-separation-the-agent-must-not-be-able-to-rewrite-its-own-skills). For a root of `.claude/skills` the parent is `.claude`, which an agent identity often owns. + +**Some valid keys cannot be directory names.** Each key becomes one directory name, so `writeSkills` rejects, as a per-skill `error` action, a key over 255 bytes and the 22 Windows reserved device names (`con`, `prn`, `aux`, `nul`, `com1`–`com9`, `lpt1`–`lpt9`). The check runs on every platform. The key stays valid everywhere else: an AI Config referencing `aux` still parses, and its other skills still materialize. The 255-byte limit is per path component, so `//SKILL.md` can still exceed Windows' 260-character `MAX_PATH`; keep the root short. + +**Skill content is opaque to the SDK.** `Skill.content` is a `Uint8Array` of the exact bytes that were hashed, and the SDK never parses or decodes it. There is no frontmatter accessor or YAML dependency; decode and parse the bytes yourself with a parser you trust. + +**No LaunchDarkly telemetry is emitted for skills.** Signals go through an internal no-op emitter; `client.track()` is never called and no LD context is involved. + +#### Receiving skills from LaunchDarkly + +`InMemorySkillStore` is for tests and bring-your-own-content. In production, skill content arrives through `FDv2SkillStore`, which uses LaunchDarkly's SDK-facing FDv2 delivery channel (the `GET /sdk/poll` and `GET /sdk/stream` endpoints the base SDK's FDv2 data source uses), authenticated with the environment's server-side SDK key. + +```ts +import { FDv2SkillStore, setSkillStore, watchSkills } from '@launchdarkly/ai-server/experimental'; + +const store = new FDv2SkillStore(process.env.LD_SDK_KEY!).start(); +if (!(await store.waitForSkills({ timeoutSeconds: 10 }))) { + // No payload arrived. Reconciling now would find an empty store; see below. + console.warn(`skill delivery has not answered yet: ${store.failed ?? 'still waiting'}`); +} +setSkillStore(store); + +// Materialize now, and re-materialize whenever delivery changes. The report is +// the initial reconcile's; `onReconcile` sees the delivery-triggered ones. +const { report, watcher } = await watchSkills('*', '.claude/skills', { + debounceMs: 500, // the default: how long a burst of changes waits before one reconcile runs + onReconcile: (next) => { + if (!next.ok) for (const action of next.errors) console.error(`skill ${action.key}: ${action.error}`); + }, +}); +try { + // ... +} finally { + await watcher.close(); + await store.close(); +} +``` + +**A reconcile that runs before delivery answers does not prune.** A store still waiting for its first payload looks the same as an environment with no skills, and `writeSkills('*')` would otherwise treat that as every skill revoked and delete the files from a previous run. `FDv2SkillStore` reports readiness through the optional `isInitialized()`; until it is true, a reconcile reports the retrieval unavailable (`report.ok` is `false`, and the error names the remedy) and leaves the disk alone. A store without `isInitialized()`, such as `InMemorySkillStore`, is treated as initialized. + +**`watchSkills` is one watcher per root.** It registers the store's change listener, runs `writeSkills` once, and returns that report with a `SkillWatcher`. + +- The store must implement the optional `addListener`; otherwise `watchSkills` throws rather than degrading to a one-shot reconcile. +- Delivery changes are coalesced over `debounceMs` (default `SKILLS_DEFAULT_DEBOUNCE_MS`, 500 ms), so a payload of forty objects runs one reconcile, not forty. `onReconcile` receives each of those reports, never the initial one. +- The watcher exposes `reconciles` (re-reconciles completed), `notify` (the registered listener, for tests), and `close()`, which detaches and awaits any reconcile in flight. +- Do not point two watchers at one root, or call `writeSkills` on a watched root yourself: two interleaved reconciles of one root lose manifest entries. +- `debounceMs` is in **milliseconds**, while `timeout` in the same options is in **seconds**, as are every `FDv2SkillStore` time option and `waitForSkills`' `timeoutSeconds`. The debounce is the one millisecond value in the skills API. + +**`waitForSkills` orders boot against the first payload.** It takes `{ timeoutSeconds }` (default 10 seconds), like the base SDK's `waitForInitialization`, and rejects a bare number. It resolves `true` once a payload is committed, or a `304` confirms the held payload is current. It resolves `false` on timeout, or immediately once the store is closed or delivery stops for good on a fatal status (for example, an unauthorized key), including for waits already pending, so a boot gated on it does not proceed on a dead store. Read `failed` to tell a store that gave up from one that timed out; closing a store yourself leaves `failed` as `null`. + +**`close()` is final.** `start()` throws on a closed store rather than opening a second connection. A closed store still answers from the content it received, so construct a new store only if you need delivery again. + +**`addListener` observes skill changes only.** Only `'skill'` objects are dispatched, so registering under any other kind throws rather than silently never firing. `removeListener` accepts any kind, so detaching on close can be unconditional. `watchSkills` is the intended consumer of both. + +**A 422 means this connection will never be assigned a skill payload, and delivery stops.** Every request declares the payload it wants (`kinds=agent-skill`), and LaunchDarkly answers HTTP 422 when it will not serve one. + +- **Causes:** a view-scoped SDK key, which cannot be assigned a skill payload (use a key that is not view-scoped), or Agent Skills delivery not being enabled for your account (contact LaunchDarkly support). +- **What the store does:** retrying cannot help, so it gives up. `failed` carries the reason, `lastError` is set, and `waitForSkills` resolves `false` immediately instead of at your timeout. The 422 does not count toward `connectionFailures`, which tracks recoverable failures only. +- **Not the empty case:** an environment with zero skills is served an empty payload that commits normally. +- **Recovery:** once the cause is fixed, call `start()` on the same store. Only `close()` is final: a restart clears `failed`, starts `connectionFailures` and the backoff over, and held content stays readable throughout. Restarting the process also works. + +**A response over the transport's memory bound also stops delivery.** The store holds at most 64 Mi characters from one poll body or one streamed event, far above any real payload. Past that, nothing from the response is applied, and it is accounted like a 422: `failed` and `lastError` are set, `connectionFailures` does not move, and the store keeps serving what it already held. The payload's size belongs to the environment, not the connection, so a retry would download it again only to be refused the same way. Recovery is the same as for a 422: once the payload is back under the bound, call `start()` on the same store, or restart the process. + +**Nothing above the store changes.** The accessors, integrity verification, and `writeSkills` see raw objects through the `SkillStore` interface and cannot tell which store produced them. + +**Server-side only.** Skills are for server-side agent runtimes and skill content is customer-confidential. A mobile key (`mob-…`) or a client-side environment ID throws from the constructor. + +**The SDK key goes only where you pointed it.** `baseUri` and `streamUri` must be `https://` (plain `http://` is allowed only to a loopback host, for a local test double). Redirects are never followed, so a 3xx stops delivery instead of forwarding the key to the `Location` host. + +**Polling and streaming have separate hosts.** By default `/sdk/poll` goes to `https://sdk.launchdarkly.com` and `/sdk/stream` to `https://stream.launchdarkly.com`. Pass `baseUri` alone to use one host for both, or `streamUri` as well to set them independently. + +**Streaming is the default, and it is what makes revocation fast.** A `delete-object` reaches a live stream in seconds; with `mode: 'poll'` it arrives within one `pollIntervalSeconds` (default 30). With `watchSkills('*', …)`, a revoked skill's `SKILL.md` leaves the disk without a restart. With an explicit list such as `skillRefs(config)` it does not: a requested skill the store no longer holds stays in the requested set as an `error` action and is not pruned, and the watcher listens only to the skill store, not to flag changes, so unpinning a skill from a config is not seen either. Re-run `writeSkills` with a fresh list for those. During an outage the store keeps serving its last content, and `writeSkills`' default `onUnavailable: 'keep'` leaves managed files alone, so an outage does not read as "everything was revoked". + +**Without the watcher, the revocation bound is process lifetime.** If you call `writeSkills` once at boot and never run `watchSkills`, a skill revoked after boot stays on disk, and in the agent's context, until the process reconciles again. To pull a skill immediately, restart or re-run `writeSkills`. Neither recalls content an agent has already read into a conversation. + +**One network timeout, and its default depends on the mode.** `readTimeoutSeconds` bounds every step of a request, connecting included. In `mode: 'poll'` it bounds the whole request (default 10 seconds); in `mode: 'stream'` it bounds each wait for the next bytes (default 300 seconds, well beyond LaunchDarkly's heartbeat interval). A stream that goes quiet past it, or dies mid-body, reconnects. Every retry delay, including one requested with `Retry-After`, is capped at `maxBackoffSeconds` (default 30). Both backoff options must be positive and finite, and `initialBackoffSeconds` (default 1) may not exceed `maxBackoffSeconds`: with no failure bound, they are the only limit on how fast a failing connection is retried. The delay grows on every reconnect, including a server-initiated recycle, and starts over at `initialBackoffSeconds` only after a stream has stayed open for 60 seconds, or a poll has completed. Recoverable failures are retried for the life of the store, so during an outage `failed` stays `null`, `connectionFailures` keeps counting, and `waitForSkills` runs to its timeout; only a fatal status stops delivery. + +**The connection carries only skills.** Every request declares the skill payload, so flag and segment objects do not arrive on it. Any other object kind is skipped, not rejected, and counted in `diagnostics.objectsIgnored`; a nonzero count means the payload has a kind this version does not recognise, not that something failed. + +> **Beta caveats, worth knowing before you deploy.** Payload signing does not exist on this channel yet, so delivery is TLS-only and the content hash establishes self-consistency, not origin authenticity. The FDv2 protocol is opt-in per account: without it the endpoints return HTTP 403, which the store reports as a fatal error explaining what to do. `ld-relay` does not speak the FDv2 endpoints, so relay-only deployments cannot receive skills. + +**If every skill comes back empty, check `diagnostics.hashlessObjects`.** Objects without a `contentHash` are withheld, so a nonzero count means skills are being withheld, not that the environment has none. The count is cumulative, not the current size of the withheld set. The store logs an error per hashless object, plus a summary when its contents become wholly hashless or the withheld set changes (not repeated for an unchanged re-delivery). There is no fallback that skips verification. + +#### Observability: integrity failures are logged for your SIEM + +A skill that fails integrity verification is **withheld**: the accessor returns `null`, `writeSkills` reports an `error` action, and no unverified byte reaches your agent. Each failure also writes one machine-parseable line to `console.error`: + +```text +[LaunchDarkly] ld.skills.integrity_failure {"action":"withheld","event":"ld.skills.integrity_failure","expected_hash":"5f2b...","language":"typescript","observed_hash":"9c14...","reason":"content hash mismatch","reason_code":"hash_mismatch","skill_key":"pdf-extraction","version":2} +``` + +The line is a `[LaunchDarkly] ` prefix, the event name, a space, and one JSON object. To ingest it, match `ld.skills.integrity_failure` and parse from the first `{`. + +**The record is written regardless of telemetry configuration.** It is not sampled, batched, or dependent on a LaunchDarkly connection. If you send LaunchDarkly nothing, this record is your complete detection surface for tampered or malformed skill content. + +**Changes to `ld.skills.integrity_failure` are always announced.** Agent Skills is experimental, so like every name in this section the event name and its fields may change in a minor release. Any rename or removal gets a changelog entry under **Experimental**, so check the changelog before upgrading if you alert on this record. Once Agent Skills is promoted out of experimental, they change only in a major release. + +| Field | Always present | Value | +|---|---|---| +| `event` | yes | `ld.skills.integrity_failure`. | +| `action` | yes | `withheld` — the content was not returned or written to disk. | +| `skill_key` | yes | The skill key **requested**, or `` when the key itself failed validation. | +| `reason_code` | yes | A stable token from the vocabulary below. Alert on this, not on `reason`. | +| `reason` | yes | Human-readable detail, including byte counts. Wording may change between releases. | +| `language` | yes | `typescript`. The Python SDK emits the same record with `python`. | +| `served_key` | no | Only on `key_mismatch`: the key the store answered under, redacted like `skill_key`. | +| `served_version` | no | Only on `version_mismatch`: the version the store answered with, as an integer, or ``. Never on the same record as `served_key`. | +| `version` | no | The delivered version, or on `version_mismatch` the version **requested**. Always an integer. Omitted when not an integer >= 1, and on `key_mismatch`. | +| `expected_hash` | no | The delivered `contentHash`, or `` when it was not 64 lowercase hex characters. Omitted when the failure happened before any hash was read. | +| `observed_hash` | no | The sha256 this SDK computed. Omitted when the failure happened before hashing. | + +Optional fields are **omitted, never null**, so an absent `observed_hash` means no hash was computed. The record never contains skill content, filesystem paths, or credentials; the key and expected hash are shape-checked and replaced with the placeholders above when malformed, so a hostile store cannot use the log line to exfiltrate a skill body. + +| `reason_code` | What happened | +|---|---| +| `not_an_object` | The store served something that is not an object. | +| `invalid_key` | The key does not match `^[a-z0-9][a-z0-9-]*$` within 256 characters. | +| `invalid_version` | The version is not an integer >= 1. | +| `missing_content` | `content` is absent or not a string. | +| `missing_content_hash` | `contentHash` is absent or not a string. | +| `not_utf8` | The content has no UTF-8 encoding (a lone surrogate), so there are no bytes LaunchDarkly could have hashed. | +| `over_size_cap` | The content exceeds the internal `MAX_SKILL_CONTENT_BYTES` cap, whatever it hashes to. The reason string names the bound. | +| `hash_mismatch` | The content does not hash to the delivered `contentHash`. | +| `key_mismatch` | The store answered under a different key than requested. Adds `served_key`; records **no** `AgentControl Skill Integrity Failure` signal. | +| `version_mismatch` | The store answered a version pin with a different version. Adds `served_version` (`version` is the one requested); records **no** `AgentControl Skill Integrity Failure` signal. Reported to callers as `wrong_version`. | + +These ten tokens are the whole vocabulary. The Python SDK emits the same ten for the same conditions, with identical JSON key order, so one parser and one alert rule cover both. + +**Page on `hash_mismatch`.** It means content and its declared digest disagree, a possible sign of **active tampering** in transit, in a cache, or in whatever backs your `SkillStore`. Most other codes indicate a malformed store, a bad deployment, or a truncated response. If all your content comes from LaunchDarkly, `over_size_cap` and `not_utf8` should never occur and are worth alerting on too. + +**`key_mismatch` and `version_mismatch` skip the product signal.** Both are detected after verification passes, and the usual cause is a bug in a custom `SkillStore` adapter (a stale cache entry, a colliding key, a wrong index lookup) rather than tampering, so neither inflates LaunchDarkly's integrity counter. Both still write this record, so a rule on `ld.skills.integrity_failure` catches them. Neither is reachable through `FDv2SkillStore`, so if it is your only store, treat them like `hash_mismatch`; behind a custom adapter, suspect the adapter first. + +`getSkill` returns `null` for a `version_mismatch` like any other failure, so this record is the only place it is visible unless you use `getSkillResult`, which reports it as the `wrong_version` outcome (below). + +#### Fail closed on tampering: `getSkillResult` + +The log record is for operators; `getSkillResult` is for your application. It runs the same retrieval and verification as `getSkill`, but reports which of five outcomes happened instead of collapsing them all to `null`. + +```ts +import { getSkillResult } from '@launchdarkly/ai-server/experimental'; + +const outcome = await getSkillResult('pdf-extraction', { version: 2 }); + +switch (outcome.reason) { + case 'ok': + return outcome.skill; // non-null exactly here + case 'integrity_failure': + // Content and its declared digest disagreed, or the store answered under a + // different key than requested. Treat skill delivery as tampered with. + console.error(`refusing to start: ${outcome.detail}`); + process.exit(1); + case 'absent': + // Nobody configured this skill, or it was revoked. Ordinary; carry on. + return null; + case 'wrong_version': + // The store answered the pin with a *different* version. This also writes + // an `ld.skills.integrity_failure` record for your SIEM. + return null; + case 'store_unavailable': + // The store could not answer. An outage, not a revocation — retry or run + // degraded. + return null; +} +``` + +| `reason` | `skill` | What happened | +|---|---|---| +| `ok` | the skill | Retrieved and verified. | +| `absent` | `null` | The store holds nothing under that key: not configured, not yet delivered, or revoked. | +| `integrity_failure` | `null` | Content was delivered and did not verify, or the store answered under a different key (`reason_code: key_mismatch`), so it was withheld. **The one to fail closed on.** | +| `store_unavailable` | `null` | The store threw. Nothing was retrieved, so nothing is known either way. An outage, not a deletion. | +| `wrong_version` | `null` | A version was pinned and the store answered with a different one, so the answer was withheld. Also logged, as `reason_code: version_mismatch`. | + +`detail` is human-readable and safe to log or show an operator: it names the key, the requested and held versions, and the failure category, never skill content or a filesystem path. It is `null` for `ok`. Branch on `reason`, not `detail`. + +**`getSkill` is unchanged.** It still resolves to `null` for all four failures and rejects only when no store is configured. Both accessors run the same lookup and verification; `getSkillResult` adds no second log record or signal, so switching to it does not double-count anything. + +`getSkills` and `allSkills` have no outcome-reporting form: they omit entries they could not return. Use `getSkillResult` per key when you need the reason. + +#### Privilege separation: the agent must not be able to rewrite its own skills + +**Run `writeSkills` as a different identity than the agent.** Reconcile as one user, run the agent as another. The reconcile sets modes explicitly rather than from the process umask: skill files and the manifest at `0644` (applied to the open file handle, so it cannot be redirected), per-skill `//` directories at `0755` (keeping a setgid bit inherited from a setgid root, so a shared group still propagates), and never the execute bit. Those modes only protect anything if the two identities differ. + +**What to verify, as the identity that will run the agent.** The SDK cannot check this for you (see below), so make it a deployment step. The agent's identity must have no write access to: + +- the managed root itself, +- the per-skill directories `//` and the files `//SKILL.md`, +- the manifest at `/.launchdarkly-skills.json`, +- **the root's parent, and every ancestor directory above it.** Write access there lets the root be renamed aside and replaced with a symlink, redirecting the agent's own skill lookups to a directory the agent controls, and on macOS and Windows the reconcile's writes and deletes as well. In the layout `/.claude/skills` the parent is `.claude`, which an agent identity is likely to own. + +```bash +# Run as the agent's user. Every line should print DENIED. +root=.claude/skills +targets="$root $root/.launchdarkly-skills.json $root/*/ $root/*/SKILL.md" + +# Every ancestor of the root, up to /: write access to any of them is enough to +# swap the root itself for a symlink. +ancestor=$(dirname "$(cd "$(dirname "$root")" && pwd)/$(basename "$root")") +while :; do + targets="$targets $ancestor" + [ "$ancestor" = / ] && break + ancestor=$(dirname "$ancestor") +done + +for target in $targets; do + [ -e "$target" ] || continue + if [ -w "$target" ]; then echo "WRITABLE — fix this: $target"; else echo "DENIED: $target"; fi +done +``` + +The managed root's own mode is **yours, not the SDK's**: `writeSkills` creates only that leaf directory, with the process umask, because you chose the path. `chown reconcile-user:agent-group` and `chmod 0755` on the root is what makes the rest of the tree's modes meaningful, and it is what denies the macOS and Windows race described under *Security posture*. + +**Why this is the mitigation that matters.** A `SKILL.md` is agent *instructions*: an agent that can write its skills directory can rewrite its own instructions, and an agent handling untrusted input may be induced to. The manifest is more sensitive still, because it tells the next reconcile which paths the SDK owns and may delete; editing it can keep a revoked skill or aim the SDK's delete at something else. `writeSkills` re-validates every manifest entry as untrusted input, but an agent that cannot edit it at all is the stronger position. + +Ancestors matter for the same reason. An identity that can rename a directory above the root can substitute the whole tree, with no race involved, and the agent then loads skills it wrote itself. Descriptor pinning inside `writeSkills` cannot prevent that, because the substituted tree is what the agent reads. + +**The SDK does not report whether the root is writable.** It knows only its own identity, which just wrote there. It cannot know which identity will run the agent, so any check would answer the wrong question and look like reassurance where caution is needed. + +--- + ### Utility Helpers ```ts @@ -266,3 +620,18 @@ All types are re-exported from this package. Handler packages import them from h | `ProviderGraphResponse` | The value returned by `graph(...).invoke()`: `{ response, usage, judgeResults? }` | | `GraphStreamEvent` | Events yielded by `graph(...).stream()`: `node_start` / `chunk` / `node_done` / `handoff` / `done` | | `GraphTopology` | The parsed graph flag shape (`root` + `edges`) | +| `Skill` | A verified skill document: `key`, `version`, `content` (`Uint8Array` — the verified verbatim bytes), `contentHash`, `name`, `description` | +| `SkillReference` | A version-pinned pointer to a skill: `{ key, version }` | +| `SkillOutcome` | What `getSkillResult()` resolves to: `{ skill, reason, detail }`. `skill` is non-null exactly when `reason` is `'ok'` | +| `SkillOutcomeReason` | The closed set of retrieval outcomes: `'absent' \| 'integrity_failure' \| 'ok' \| 'store_unavailable' \| 'wrong_version'` | +| `SkillStore` | The structural seam skill content is retrieved through: `getObject(kind, key, version?)`, `allObjects`, optional `isInitialized()`, `addListener` / `removeListener`. A store without `isInitialized()` is treated as initialized. | +| `RawSkillObject` | The wire shape a `SkillStore` serves, before verification. Every field is untrusted. | +| `ReconcileReport` | The result of `writeSkills()`: `{ actions, ok, errors }` | +| `ReconcileAction` | One outcome from a reconcile: `{ key, action, version, path, error }` | +| `ReconcileActionKind` | The closed set of reconcile outcomes: `'written' \| 'updated' \| 'skipped_current' \| 'removed' \| 'error'` | +| `OnUnavailable` | `'keep' \| 'raise'` — how `writeSkills` reacts to content it could not retrieve | +| `WriteSkillsOptions` | Options accepted by `writeSkills()` (`prune`, `timeout` in seconds, `onUnavailable`) | +| `WatchSkillsOptions` | `WriteSkillsOptions` plus `debounceMs` (milliseconds) and `onReconcile` — what `watchSkills()` accepts | +| `FDv2Mode` | `'stream' \| 'poll'` — the `mode` option of `FDv2SkillStore` | +| `FDv2SkillStoreOptions` | Options accepted by the `FDv2SkillStore` constructor (`mode`, `baseUri`, `streamUri`, `pollIntervalSeconds`, `readTimeoutSeconds`, `initialBackoffSeconds`, `maxBackoffSeconds`, all in seconds) | +| `StoreDiagnostics` | The read-only counters `FDv2SkillStore.diagnostics` returns | diff --git a/packages/client/agents.md b/packages/client/agents.md index 151e1382..8d51e1d7 100644 --- a/packages/client/agents.md +++ b/packages/client/agents.md @@ -27,37 +27,295 @@ No other `@launchdarkly/ai-*` package may define or duplicate these. They import | `src/client.ts` | `config()` | | `src/tracking.ts` | `executeAndTrack`, `executeAndStream`, `wrapToolHandlers` | | `src/graph.ts` | `graph()`, `resolveGraph()` | -| `src/types.ts` | All shared TypeScript types — including owned `LDContext`, `LDClientInterface`, `LDClientInterface` | +| `src/types.ts` | All shared TypeScript types — including owned `LDContext`, `LDClientInterface`, `LDClientInterface` — plus the Agent Skills value types, their freezing factories, and `parseAiConfig`'s validators | | `src/utils.ts` | `parseTemplate`, `parseJSONWithPossibleFences`, `createHandler` | | `src/registry.ts` | `Registry`, `globalRegistry`, `compose` | -| `src/index.ts` | Public barrel — the only surface handler packages import from | +| `src/shutdown-hooks.ts` | The cleanup hooks `shutdown()` runs — how core clears experimental state without importing it | +| `src/skills-core.ts` | Agent Skills internals shared by the two layers above it: the store and telemetry seams, module state, integrity verification, store resolution | +| `src/skills.ts` | `skillRefs`, the content accessors, `InMemorySkillStore`, and the documented test-injection hooks | +| `src/skills-fdv2.ts` | Agent Skills delivery transport — the FDv2 protocol, the wire-key/`version` translation, the held object set, and `FDv2SkillStore`. Sits **below** the store seam; imports `skills-core` only, and nothing imports it | +| `src/skills-watch.ts` | Agent Skills eager re-reconcile — `watchSkills` / `SkillWatcher`, wiring the store's change listener to `writeSkills`. Sits **above** `skills-fs` and modifies none of it | +| `src/skills-fs.ts` | `writeSkills` — the manifest format, on-disk filenames, and reconcile semantics | +| `src/safe-fs.ts` | Symlink-refusing filesystem primitives. Knows nothing about skills; owns the single interceptable rename and unlink call sites | +| `src/index.ts` | Public barrel — the package root, and the only surface handler packages import from | +| `src/experimental.ts` | Experimental barrel — `@launchdarkly/ai-server/experimental`. Agent Skills is exported here and only here | + +The Agent Skills module dependencies run **one way only**: + +- `types` ← `skills-core` ← `skills`, and `types` + `safe-fs` + `skills-core` ← `skills-fs`. `skills-core.ts` imports neither `skills.ts` nor `skills-fs.ts`. +- `skills-fdv2.ts` sits *below* the seam and imports only `skills-core` + `types`; nothing imports it. +- `skills-watch.ts` sits *above* `skills-fs` and imports it without being imported back. + +Do not add an edge that closes a cycle. The store and the emitter live in `skills-core.ts` so the accessor and filesystem layers cannot disagree about whether one is configured. + +--- + +## Agent Skills — the delivery transport, and the one field that will bite you + +`FDv2SkillStore` speaks LaunchDarkly's SDK-facing FDv2 channel (`GET /sdk/poll`, `GET /sdk/stream`, server-side SDK key in `Authorization`, a `basis` param once a payload has committed, `If-None-Match`/304). It sits below the seam and produces raw objects in the shape `SkillStore` documents; **nothing above the seam knows it exists**. If a transport change seems to require editing an accessor, verification, or `writeSkills`, the adapter boundary is wrong. + +**The skill's version is in the object's `key`. `version` is the payload's.** Each version of a skill is its own object on the wire, identified as `:`: + +```json +{"key":"pdf-extraction:3","kind":"skill","version":42, + "object":{"contentType":"text/markdown","content":"…","contentHash":"…","name":"…"}} +``` + +The `3` after the delimiter is what a `{key, version}` reference pins; it becomes the seam's `version`, under the seam key `pdf-extraction`. `version` (42) is the version of the *payload*, and moves when anything in the environment moves, including an unrelated flag. Reading it as the skill's version fails **silently**: the object verifies, the hash matches, and the caller gets content under a meaningless version. Objects carry only `key`, `kind`, `version` and `object`, like a flag, so there is no other field to read. + +- `splitWireKey` is the only place the wire key is read. `seamObjectFromPut` and `tombstoneFromDelete` both go through it, and the `version translation` suite asserts both directions. +- A wire key that will not split cleanly is *held*, not dropped (version-less, or with the offending text as its version), so verification withholds it as `invalid_version` under a key the caller recognises. Only a key with nothing before the delimiter is dropped. + +**Skills are identified by `kind === 'skill'`; other kinds are ignored, not rejected.** Object kinds are open strings, and a skill arrives under the bare kind its producer registered. With the `kinds` declaration below, flag and segment objects should not arrive at all, but the skip stays and stays tested: throwing on an unknown kind would turn a payload that gained a new object kind into a permanent reconnect loop — a flag-delivery outage caused by a skills rollout. + +**Every request declares `kinds=agent-skill`.** Delivery defaults to flags, so without it the store is served the flag payload and holds no skills while reporting healthy. It also narrows the connection to the one payload `ProtocolReader` is built for; otherwise a skill-enabled environment assigns two, and the reader warns and reads only the first. `FDV2_PAYLOAD_KIND` (the payload) and `FDV2_OBJECT_KIND` (the objects inside it) are different strings, asserted apart by source text so an alias cannot pass. + +**No `mv` parameter.** It selects the *flag* data model, and delivery ignores it for non-flag payloads. + +**HTTP 422 is fatal.** Delivery answers 422 when a connection's declared kinds exclude every payload assigned to it, and chose a non-400 4xx because LD SDKs treat those as terminal. `classifyStatus` returns a `FatalTransportError` and the normal give-up path runs: `failed` and `lastError` are set, `waitForSkills` resolves `false` at once, and `connectionFailures` is left untouched (it counts consecutive *recoverable* failures, and a fatal never retries). + +**Polling and streaming have different default hosts.** `GET /sdk/poll` uses `SKILLS_DEFAULT_BASE_URI` (`sdk.launchdarkly.com`) and `GET /sdk/stream` uses `SKILLS_DEFAULT_STREAM_URI` (`stream.launchdarkly.com`), as in the base server-side SDK. The fake endpoint serves both from one origin, so tests cannot catch a stream request sent to the polling host. A lone `baseUri` applies to both, since a relay or private instance usually serves both from one host; `streamUri` overrides the stream origin on its own. + +**Changes commit at `payload-transferred`, not as objects arrive.** A payload version is the unit of consistency. A half-applied full transfer would publish a state the server never described and briefly empty the store, which with pruning on deletes skill files. An interrupted transfer keeps last known good. Listeners fire once per changed object, all at `payload-transferred` — which is why `watchSkills` debounces. + +**A commit is the only thing that publishes a first payload, and a 304 is not one.** `isInitialized()` — the fact `writeSkills('*')` authorizes a prune on — goes true when a payload *commits*, so every other answer has to stop short of claiming one. A `payload-transferred` that applied nothing reports neither a commit nor an up-to-date answer, and does not adopt the selector of a payload it never applied: a `none` intent that no object followed builds no pending set, nor does an `intentCode` this SDK does not recognise, and a foreign payload's contents are declined. A poll adopts the response `ETag` only from a body that completed an exchange — a commit, or a `none` intent, which is the server saying the content held is what the etag describes; an unrecognised intent says the opposite, since the body carried objects `ignoreUnderUnknownIntent` dropped, and leaving that poll unconditional is what keeps the body arriving and the warning repeating instead of silenced behind a 304. And a 304 *confirms* the payload held rather than establishing one, because the exchange it stands in for cannot establish one either. Loosen any of the three and the other two carry a store that received nothing into a prune of every managed `SKILL.md` on disk: an empty committed set reads as an environment that revoked every skill, and a 304 carries nothing to notice it on. There is no cached basis to make it safe — `basis` and `etag` both start `null` with no injection point, so a 304 reaching a store that holds nothing takes a server answering a request that carried no etag at all. + +**`objectsRevoked` counts revocations, not tombstones.** A full transfer revokes by omission, so `payloadTransferred` diffs the committed set against the pending one and pushes departures into `changes` as tombstones per `(key, version)`: a listener that reads versions needs both halves of a version move. The counter, which operators alert on, counts only a key the payload dropped altogether; a key surviving under a new version was not revoked. `keysFullyRevoked` is where the two granularities part. + +**The first payload intent is read, and assumed to be the skill payload.** The `kinds` declaration narrows the connection to one payload and the protocol says to ignore all but the first intent, so `payloads[0]` is read. The risk: an `xfer-full` for *another* payload would start an empty pending set, and the next `payload-transferred` would publish it — every skill revoked, and with pruning on, files deleted. + +- `ProtocolReader` therefore learns the skills payload (from the intent's `id` or the `(p::)` selector) and declines a transfer of any other: one warning, counted in `diagnostics.payloadsIgnored`, last known good kept. +- A transfer that names no payload is applied, since one-payload delivery is the common case. +- The first transfer on a connection has nothing to compare against; the separate warning on a multi-payload intent covers it. + +**A hashless object is held, not dropped.** Verification withholds it as `missing_content_hash`, and the transport makes that loud: an error per object, a summary each time the held store becomes wholly hashless or its withheld set changes, and `diagnostics.hashlessObjects`. Dropping it would report `absent` — indistinguishable from "no such skill" — and let a prune delete the last known-good copy on disk. Never synthesize a hash from the delivered content; that verifies nothing. + +- Neither error repeats for a payload re-delivered unchanged, which matters in polling mode, where the same payload arrives every interval. +- What has been reported is tracked in each `ProtocolReader`'s own `HashlessMemory` (exposed to tests as `_warnedHashless`): per store, so two stores in one process do not suppress each other's reports, and capped, so a frequently versioned environment cannot grow it without bound. + +**`SkillObjectSet.snapshot` collapses to one object per key, keyed by the bare skill key.** `//SKILL.md` is a single path, so a whole-store consumer must see one object per key; otherwise a `'*'` reconcile writes one path twice and `allSkills` returns two versions of a skill. Both consumers also collapse for themselves through `newestByKey`, because the seam admits any store. The keys must be skill keys, not wire `key:version` keys, because `writeSkills('*')` derives its prune keep-set from them. `getObject` still resolves a pinned version from the full set. + +**There is one network timeout.** `readTimeoutSeconds` is applied, converted by `secondsToMs`, through a `ReadDeadline` composed with the store's abort signal, so connect, headers and each body read share it. Its default is per mode: `DEFAULT_POLL_TIMEOUT_SECONDS` for a whole poll request, `DEFAULT_STREAM_READ_TIMEOUT_SECONDS` for the gap between reads on a stream (tripping it on a quiet stream *reconnects*). The `timeouts` suite measures the bound against a socket that accepts and never answers. Do not add a separate connect timeout. + +**Every `FDv2SkillStore` time option is in seconds**, as in the Python SDK and the base Node SDK: `pollIntervalSeconds`, `readTimeoutSeconds`, `initialBackoffSeconds`, `maxBackoffSeconds`, and `waitForSkills({ timeoutSeconds })`. `watchSkills`' `debounceMs` is the one millisecond option in the skills API. + +- Values stay in seconds until `secondsToMs` at the `setTimeout` or `ReadDeadline` they set. It rounds rather than truncates (`1.005 * 1000` is `1004.9999999999999`) and floors a positive option at 1 ms, so a sub-millisecond value never becomes a zero delay. Validate in seconds, before converting. +- A unit change is a rename, never a reinterpretation of an existing name. The retired `...Ms` option keys are not read. +- `waitForSkills` takes an options object because a positional argument cannot be renamed: `waitForSkills(10_000)` would silently become almost three hours. A bare number rejects with a `TypeError` naming the new form. + +**A body read that fails is recoverable; a protocol-reader or dispatch error is not.** + +- `iterSse` wraps read failures (a reset, a truncated chunk, the read deadline) as `RecoverableTransportError`, since a live stream dies mid-body far more often than it refuses to open. The delivery loop treats anything else as a bug and stops for the process lifetime. +- A known streamed event whose `data` is not JSON is a `RecoverableTransportError` too, never skipped. Skipping it let the `payload-transferred` after it commit the transfer without it and advance `basis` past it, so a lost `delete-object` kept a revoked skill served and the reconnect never asked for it again, and a lost `put-object` in an `xfer-full` revoked that skill by omission. Ending the connection abandons the in-flight payload, and the reconnect resumes from the last committed basis, as js-sdk-common's `PayloadStreamReader` does (it reports `InvalidData` and the streaming processor stops the source). Both cases have a store-level test. +- Only the events in `EVENTS_WITH_DATA` (the six `ProtocolReader.handle` reads) are parsed. A `heart-beat` or an unknown event is yielded with `null` data, so its data cannot end the connection: unknown events are ignored by contract, and `PayloadStreamReader` attaches handlers to those six only. Empty data on one of the six is not JSON either and ends the connection the same way (`PayloadStreamReader` reports a known event with no data as an error too): read as `null`, a `delete-object` would be ignored and a `payload-transferred` would commit with no selector. +- A put or delete that is valid JSON but has no usable `key` is warned and ignored, for the experimental stage. Its transfer still commits, so a keyless `delete-object` loses its revocation, and a keyless `put-object` in an `xfer-full` revokes that skill by omission (and `writeSkills('*')` prunes it). Base `ldclient` interrupts instead. Interrupting is not strictly safer, because a server that keeps resending the object would then block every later change, so the choice is to be revisited before 1.0 (TESTING.md §3.25). Two protocol-reader tests pin the current rule, so changing it fails them. +- An error thrown by the consumer's loop body while the generator is suspended at a `yield` passes through unwrapped, so a bug still surfaces as one. +- A poll body or streamed event over `MAX_RESPONSE_CHARS` is a `FatalTransportError`, not a read failure, and takes the give-up path like a 422 (`failed` and `lastError` set, `connectionFailures` untouched). The size belongs to the environment, so a retry would re-download up to 64 Mi characters on every backoff step, from every process, and never set `failed`. +- Every retry delay is clamped to `maxBackoffSeconds` and floored at `initialBackoffSeconds`, so `Retry-After: 0` cannot cause a tight reconnect loop. A blank `Retry-After` means "no delay given", not zero (`Number("")` is `0`). + +**What resets the failure counter, and what escapes it.** The counter is reported as `connectionFailures`; it bounds nothing, and it no longer drives the backoff (see the next paragraph). Recoverable failures are retried for the life of the store, and there is no `maxConsecutiveFailures` option: a count bound would turn a short outage into a process that never sees another revocation. Only a fatal status stops delivery. + +- **It resets only on a completed exchange** — a committed payload, or a `none` intent — not when a connection returns. A stream only ever ends by being dropped, so resetting on return would count every healthy, server-recycled connection as a failure. +- **`none` counts** because a reconnect whose basis is already current is answered with `none` and commits nothing; requiring a commit would report a healthy stream for an environment whose skills never change as failing. +- **A parsed `xfer-full` or `xfer-changes` intent does not reset it.** A server that announces a transfer and drops before `payload-transferred`, every time, has delivered nothing, and would otherwise read as healthy in `connectionFailures` for as long as it kept doing so (pinned by the `counts each drop of a server that announces a transfer and drops` test). +- **A non-catastrophic `goodbye` is exempt from the counter** only on a connection that completed such an exchange, tracked per attempt by `reachedServer`. Otherwise a server that says goodbye before sending any intent could reconnect without limit, never reaching `diagnostics` or `failed`. An exempt `goodbye` sets no `lastError` and logs nothing above debug: `ProtocolReader` cannot tell a recycle from a failure, so it logs the goodbye at debug and leaves the warning to the delivery loop, which warns only for a disconnect that counts. + +- **A catastrophic `goodbye` is recoverable and always counted.** It sets `lastError`, logs at error level whatever `silent` says, and is never `expected`, so it counts even after a completed exchange. It is not fatal: neither js-sdk-common's protocol handler nor `StreamingProcessorFDv2` reads `catastrophe`, and the streamer sets it on its own connection-error path, so a fatal reading stopped delivery, revocations included, until `start()`. + +Keep both halves — the reset rule and the `reachedServer` qualifier. + +**The backoff step is not the failure counter.** `backoffAttempt` advances on every reconnect after a failure or a dropped stream, a `goodbye` recycle included, and returns to the first step only when the stream that just ended had been open for `BACKOFF_RESET_INTERVAL_MS` (60 s, as js-core's `Backoff` and the Python SDK's `BACKOFF_RESET_INTERVAL`), or when a poll completes (`pollIntervalSeconds` already spaces polls). Resetting it on a commit or a `none` instead would let a degraded server that answers and then drops be reconnected at `initialBackoffSeconds` by every process for as long as it stayed degraded, and with no failure bound nothing else would stop that. The interval is not an option; tests shorten it through the instance's `_backoffResetIntervalMs`. + +**The SDK key goes only where it was pointed, enforced in two places.** Every request carries the server-side SDK key in `Authorization`. + +- `requireHttpsUri` checks both `baseUri` and `streamUri` in the `FDv2SkillStore` constructor (after the lone-`baseUri` resolution, even with an injected `Requester`): `https://` with a host, or plain `http://` to loopback only (`localhost`, `127.0.0.1`, `::1`, where the test doubles listen). Anything else throws with a message naming `https://` and the defaults. +- Both fetches in `FetchRequester` use `redirect: 'manual'`, because `fetch`'s default `'follow'` copies `Authorization` onto whatever host `Location` names. Any 3xx — same host included, and an `opaqueredirect` if the runtime produces one — is a `FatalTransportError` through `classifyStatus`, handled exactly like 401/403. A 304 is not a redirect and is settled first. + +The Python SDK enforces the same pair with the same wording; change both or neither. + +**HTTP 400 is recoverable once, and only when there was state to drop.** The `basis` selector and the `If-None-Match` etag are the only client state in a request. + +- A 400 on a request carrying either arrives as `StaleRequestStateError`: the state is dropped and a full transfer is requested from scratch. A second 400 is fatal. +- A 400 on a request carrying neither is fatal at once; a retry would be byte-identical. +- Do not make 400 unconditionally fatal: a stale selector would strand delivery for the process lifetime. 405, 406, 414 and 501 stay fatal. + +**`close` aborts the signal, not just a flag.** The delivery task is awaiting a stream read, so a flag it never checks would leave a healthy stream running until exit. The store's signal is the parent of every `ReadDeadline`, so one abort reaches a pending connect and a pending read alike; a closing store is not reported as a failure. Every backoff and deadline timer is `unref`ed, so a background store never keeps `node` up. The `waitForSkills` timer is the exception and stays referenced: during an outage nothing else holds the process, so an unreffed wait let `node` exit mid-`await` (exit 13 under ESM top-level await, exit 0 without running the next line in CommonJS). It is bounded by the timeout and cleared when the wait is released. + +**The store refuses two things loudly rather than degrading.** + +- `addListener` throws for any kind but `'skill'`, in `FDv2SkillStore` and `InMemorySkillStore` alike: a listener on another kind would silently never fire. `removeListener` accepts any kind, so a consumer can detach unconditionally. +- `close` is final: `start` throws afterwards rather than opening a second delivery loop. A store that gave up on its own is different: `start` runs delivery again with the failure count and backoff started over, and clears `failed`, and is a no-op while delivery is running. Closing leaves `failed` as `null` (it is the caller's decision, not a delivery failure), and both a closed store and one that gave up answer `waitForSkills` immediately. --- ## Public Exports (`src/index.ts`) +This block is **generated from `src/index.ts`, verbatim and in its order** — it is not a curated +summary. Regenerate it rather than hand-editing, or it drifts from `index.ts`. + ```ts +export type { AiConfigRep } from './client.js'; +export { config } from './client.js'; +export type { ContentCaptureOptions, SpanMessage, SpanMessagePart, ToolDefinitionInput } from './content.js'; +export { + langChainContentText, + langChainFinishReasons, + langChainSpanMessages, + setInputContentAttributes, + setOutputContentAttributes, + setToolCallContentAttributes, + setToolDefinitionAttributes, + textMessage, + toSemconvFinishReason, +} from './content.js'; +export { + ConversationIdSpanProcessor, + setConversationIdIfAbsent, + withConversationId, +} from './conversation.js'; +export { graph, resolveGraph } from './graph.js'; +export type { CanonicalTurn, ConfigTurn } from './history.js'; +export { + anyMultimodal, + composeHistory, + contentToText, + hasMultimodalContent, + imageBlockToUrl, + isContentBlocks, +} from './history.js'; +export { buildJudgeTasks, runJudge } from './judges.js'; export type { InspectConfigResult } from './lifecycle.js'; export { getClient, initClient, inspectConfig, shutdown, shutdownTelemetry, waitForTelemetry } from './lifecycle.js'; +export { compose, globalRegistry, Registry } from './registry.js'; export { registerAiSdkPackage } from './sdk-info.js'; -export type { LDContext, LDClientInterface, LDSingleKindContext, LDMultiKindContext, LDUser } from './types.js'; -export { ConversationIdSpanProcessor, setConversationIdIfAbsent, withConversationId } from './conversation.js'; -export { config } from './client.js'; -export type { AiConfigRep } from './client.js'; +export { makeNodeTrackData, makeRunTrackData } from './tracking.js'; export type { - Tool, VariationMeta as LDVariationMeta, ProviderHandler, ProviderSetupFn, ProviderResponse, - ConfigArgs, TrackData, - GraphTopology, GraphEdge, GraphNode, GraphDefinition, GraphOptions, GraphArgs, - HandlerStreamEvent, StreamEvent, ProviderGraphResponse, + ConfigArgs, + ConfigMessage, + ContentBlock, + GraphArgs, + GraphDefinition, + GraphEdge, + GraphNode, + GraphOptions, + GraphStreamEvent, + GraphTopology, + HandlerStreamEvent, + ImageContentBlock, + JudgeCallResult, + JudgeRunResult, + JudgeTask, + LDClientInterface, + LDContext, + LDMultiKindContext, + LDSingleKindContext, + LDUser, + Message, + MessageContent, + ProviderGraphResponse, + ProviderHandler, + ProviderResponse, + ProviderSetupFn, + RegistryInput, + RouteResult, + RunNodeOptions, + StreamEvent, + TextContentBlock, + TokenUsage, + Tool, + ToolHandlerFn, + TrackData, + TraverseVisitor, + VariationMeta as LDVariationMeta, } from './types.js'; -export { GraphTopologySchema, NativeTool, NATIVE_TOOL_KEY } from './types.js'; -export { Registry, globalRegistry, compose } from './registry.js'; -export { parseTemplate, parseJSONWithPossibleFences, createHandler } from './utils.js'; -export { graph, resolveGraph } from './graph.js'; -export { parseUsage, normalizeMode, parseAiConfig } from './tracking.js'; +export { + GraphTopologySchema, + NATIVE_TOOL_KEY, + NativeTool, +} from './types.js'; +export type { RunUsage, SpanUsage } from './utils.js'; +export { + addCachedTokensToInput, + collapseMessagesToInstructions, + createHandler, + createRunUsage, + endSpanOnce, + langChainSpanUsage, + omitModelStamps, + parseJSONWithPossibleFences, + parseTemplate, + setLdSpanAttributes, + setModelIdentityAttributes, + setUsageSpanAttributes, +} from './utils.js'; ``` -When adding a new export, add it here. Handler packages must never import from sub-paths (e.g. `@launchdarkly/ai-server/dist/client`). +## Experimental Exports (`src/experimental.ts`) + +Published as `@launchdarkly/ai-server/experimental` (a subpath in `package.json` `exports`, built +alongside the root by `tsup.config.ts`). Names here may change in a minor release and are **absent +from the package root**. `@launchdarkly/ai-node/experimental` re-exports this entry point. Generated from +`src/experimental.ts` the same way as the block above. + +```ts +export { + allSkills, + getSkill, + getSkillResult, + getSkills, + InMemorySkillStore, + setSkillStore, + skillRefs, +} from './skills.js'; +export type { FDv2Mode, FDv2SkillStoreOptions, StoreDiagnostics } from './skills-fdv2.js'; +export { FDv2SkillStore, SKILLS_DEFAULT_BASE_URI, SKILLS_DEFAULT_STREAM_URI } from './skills-fdv2.js'; +export type { WriteSkillsOptions } from './skills-fs.js'; +export { MANIFEST_FILENAME, MANIFEST_VERSION, SKILL_FILENAME, writeSkills } from './skills-fs.js'; +export type { WatchSkillsOptions } from './skills-watch.js'; +export { SKILLS_DEFAULT_DEBOUNCE_MS, SkillWatcher, watchSkills } from './skills-watch.js'; +export type { + OnUnavailable, + RawSkillObject, + ReconcileAction, + ReconcileActionKind, + ReconcileReport, + Skill, + SkillOutcome, + SkillOutcomeReason, + SkillReference, + SkillStore, +} from './types.js'; +export { createSkill, createSkillOutcome, createSkillReference } from './types.js'; +``` + +The stage rules (shared spec §0.3), which every change here must keep: + +- **Core never names experimental.** No root export, core type, function signature, or option may + mention an experimental name. `AiConfigRep.skills` is therefore typed `unknown` rather than as + `SkillReference[]`, and the store is set with `setSkillStore`, not an `initClient` option. +- **An experimental field never fails a core call.** `parseAiConfig` passes `skills` through + unvalidated; `skillRefs` validates it and throws on a present but malformed field, rather than + returning a partial list that would authorize a prune. + Experimental code may import core freely. +- **Core reaches experimental only through an internal hook**, and a failure there is caught and + logged, never thrown into the core call. Core never imports an experimental module: an experimental + module registers its cleanup with `registerShutdownHook` (`src/shutdown-hooks.ts`) when it loads, + and `shutdown()` runs whatever is registered. Today the one hook is Agent Skills', registered by + `skills-core.ts`. A test asserts that loading the root registers no Agent Skills hook. +- **Both entry points are in the API report.** `etc/ai-server.api.md` (root) and + `etc/ai-server-experimental.api.md` are checked in; CI runs + `yarn workspace @launchdarkly/ai-server api:check`. After an intended surface change run + `yarn workspace @launchdarkly/ai-server api:update` (or `yarn api:update` from this directory) and + commit both reports. +- **Promotion to core** adds root exports and keeps these as `/** @deprecated use the root export */` + re-exports until the next major. + +When adding a new export, add it here. Handler packages must never deep-import build paths (e.g. `@launchdarkly/ai-server/dist/client`). The only sub-path anyone may import is the published `@launchdarkly/ai-server/experimental`, and a handler package does so only for an experimental feature it integrates with; everything else comes from the package root. + +`MAX_SKILL_CONTENT_BYTES` and `SKILL_OBJECT_KIND` are deliberately **not** exported; both stay internal to `skills-core.ts`, and an absence assertion enforces it. Do not re-export either from `index.ts` or `experimental.ts`: + +- The size cap is a local bound on content the platform produces. Exporting it would semver-lock a number this SDK does not own; the `over_size_cap` reason string already reports the bound when it withholds content. +- The kind is the SDK-side value handed to a `SkillStore`, not the wire contract, and publishing it would imply otherwise. --- @@ -155,6 +413,8 @@ This is an OTel context value, not W3C baggage, so the id does not leak onto out The core client owns all OTel initialization. `initClient()` sets up a `NodeTracerProvider` with `ConversationIdSpanProcessor` and a `BatchSpanProcessor` plus an OTLP HTTP exporter when the optional OTel peer deps are installed. +It registers the provider, an `AsyncLocalStorageContextManager` and the W3C propagators as OTel's process globals, and records which of the three it actually took: each global is one-shot, so one another library registered first stays theirs (a refused tracer provider logs a `console.warn`, since the telemetry options then have no effect). `shutdownTelemetry()` — and so `shutdown()` — releases only the globals it took, which lets a later `initClient()` register afresh without tearing down a host application's own provider. An init that fails after building its provider tears it down, so the retry builds its own with the retry's options. + **Required packages (via `@launchdarkly/ai-otel` or installed manually):** ```sh @@ -224,13 +484,42 @@ When `enabled` is `false`, `config` is always `null`. When `enabled` is `true` b - **Lazy initialization.** Importing the package does not initialize the LD client. The first API call that needs LaunchDarkly (`extractVariation`, graph resolution, etc.) calls `initClient()` internally, provided `LD_SDK_KEY` is set. - **Explicit initialization — Node SDK path.** `initClient(options?)` dynamically imports `@launchdarkly/node-server-sdk` at runtime (optional peer dep). If the package is not installed it throws with a clear message. -- **Explicit initialization — BYOC path.** `initClient(client)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for Vercel, Cloudflare, or other edge runtimes whose SDK has different init semantics. No `@launchdarkly/node-server-sdk` is required. +- **Explicit initialization — BYOC path.** `initClient(client, options?)` accepts any pre-initialized object that satisfies `LDClientInterface` — this is the path for Vercel, Cloudflare, or other edge runtimes whose SDK has different init semantics. No `@launchdarkly/node-server-sdk` is required. The optional second argument is the same options bag as the other overload, passed whole to telemetry setup (`otlpEndpoint`, `serviceName` and `environment` mean the same thing). Once a client is set, a repeat call — with the same client or a different one — returns the existing client and ignores the new telemetry options; call `shutdown()` first to swap clients. +- **Options are ignored once the client singleton exists.** The skill store is not an option: `setSkillStore(store)` (experimental entry point) sets it independently of `initClient`, on every call, so a client that initialized lazily can be given one later. A nullish argument never clears a configured store; only `shutdown()` does, and it clears skills state unconditionally, because that state can exist without a client. A throw from that clearing is logged and does not fail `shutdown()`. Any options key `initClient` does not read, a `skillStore` key included, is ignored with a generic warning that names the key but not the feature or its setter. - **Return value.** `initClient()` returns `Promise`. Callers that don't need the instance may discard the return value — this is a non-breaking change from the previous `Promise` signature. - `getClient()` throws if `initClient()` has not resolved — any code that calls `getClient()` directly must ensure initialization has occurred. - `shutdown()` must be called before process exit. It flushes OTel spans, flushes LD events, and closes the LD client. `client.close()` runs even when `flush()` throws. --- +## Dependencies + +Tier 0, so the runtime surface is deliberately tiny: two hard dependencies, and everything else either optional or dev-only. Nothing here may grow without a reason recorded in this table. + +### Runtime (`dependencies`) + +| Package | Why | +|---|---| +| `@opentelemetry/api` | The tracer/span API used on every instrumented path (`tracking.ts`, `graph.ts`, `content.ts`, `utils.ts`). API-only — the *SDK* half is an optional peer, so a consumer that never configures OTel still gets no-op spans rather than a crash. | +| `dotenv` | `.env` loading for `LD_SDK_KEY` and the OTel endpoint variables, imported as `dotenv/config` from `lifecycle.ts`. | + +### Optional peers (`peerDependencies`, every one `optional: true`) + +| Package | Why | +|---|---| +| `@launchdarkly/node-server-sdk` | The default Node client, imported dynamically by `initClient()`'s options overload. Optional because the BYOC overload (`initClient(client)`) targets edge runtimes that supply their own client, and requiring it would force an unused Node SDK into every Vercel/Cloudflare install. Absent ⇒ a clear throw, only on the path that needs it. | +| `@opentelemetry/sdk-trace-node`, `@opentelemetry/sdk-trace-base`, `@opentelemetry/resources`, `@opentelemetry/core`, `@opentelemetry/context-async-hooks` | Tracer provider, span processor, resource attributes, propagators, and async context — loaded dynamically by `setupTelemetry()`. Optional so telemetry is opt-in; see [OTel Setup](#otel-setup) for the install command. | +| `@opentelemetry/exporter-trace-otlp-http`, `@opentelemetry/otlp-exporter-base` | OTLP/HTTP export and its compression enum. Same optionality, same loader. | + +### Dev-only (`devDependencies`) — the ones with a contract attached + +| Package | Why | +|---|---| +| the optional peers, mirrored | Each optional peer is repeated here so the test suite can import it. A peer that is *only* a peer would not be installed in this workspace and its tests could not run. | +| `vitest`, `typescript`, `@types/node` | Test runner, compiler, and Node type definitions. | + +--- + ## Common Pitfalls ### 1. Calling `getClient()` before `initClient()` resolves @@ -241,13 +530,83 @@ When `enabled` is `false`, `config` is always `null`. When `enabled` is `true` b `shutdown()` is idempotent — calling it a second time is a no-op. However, if `client.flush()` throws during the first call, the singleton is still cleared so a second `shutdown()` call will not re-attempt the flush or throw. Ensure your process-exit handler does not assume `shutdown()` will re-try a failed flush. If you need guaranteed delivery, call `client.flush()` yourself and handle the error before calling `shutdown()`. +### 3. Interpreting skill content anywhere in the SDK + +`Skill.content` is a `Uint8Array`: the verified verbatim bytes that were hashed. The SDK treats it as opaque — no frontmatter accessor, no YAML dependency, and no decode step (the wire string is encoded to bytes exactly once, during verification in `skills-core.ts`). Do not add a parser, a convenience accessor, or an encoding assumption; consumers who want structure parse the bytes themselves. Same contract as the Python SDK. + +### 4. Assuming `writeSkills`'s `timeout` is in milliseconds + +It is in **seconds**, defaulting to `10`. The signature is a cross-language contract that must match the Python SDK exactly, so the usual TypeScript `timeoutMs` instinct is wrong here. + +### 4a. Passing `debounceMs` and `timeout` in the same unit + +`WatchSkillsOptions` is `WriteSkillsOptions` plus two fields, so one options bag carries `debounceMs` in **milliseconds** (the TypeScript convention) beside `timeout` in **seconds** (the cross-language contract from pitfall 4). `{ debounceMs: 500, timeout: 10 }` is the sane pair; `{ debounceMs: 0.5, timeout: 10_000 }` is a 1 ms coalescing window and a nearly three-hour reconcile budget. Both are guarded for sign and finiteness, not for plausibility. + +### 4b. Reading "no `removed` action" as "nothing is stale" + +Prune is **suppressed** — not merely empty — whenever the run cannot tell what is still current: + +- an incomplete retrieval (no store configured, a store that threw, an exhausted timeout); +- a store whose `isInitialized()` answers `false` (delivery has not sent a payload yet); +- a withholding that could not be attributed to a key (the `'*'` form's run-level `error`); +- a corrupt manifest. + +Each case puts an `error` action in the report, sets `ok` to `false`, and prunes nothing — including skills that genuinely were revoked. To know whether revocation has taken effect, read `ok` and `errors`, not the absence of `removed`. + +### 4c. Two watchers on one root, or `writeSkills` on a watched root + +Run one reconcile per root at a time. Two interleaved runs read the same manifest and each writes it back from its own picture, so the loser's entries vanish while its files stay on disk unmanaged. `SkillWatcher` serializes its own reconciles, but cannot see a second watcher on the same root or a caller's own `writeSkills` against it. Do neither. + +### 4d. Expecting revocation to reach a boot-only `writeSkills` deployment + +With an explicit list, `watchSkills` does not close the gap either. A requested skill the store answers `absent` for stays in the requested set as an `error` action and is not pruned, and the watcher listens only to the skill store, so unpinning a skill from an AI Config is not seen. Only `watchSkills('*', …)` removes a revoked skill from disk without a re-run. + +Without `watchSkills`, the revocation bound is process lifetime: a skill revoked after boot stays on disk until the process reconciles again, so a restart (or an explicit re-run of `writeSkills`) is the incident-response action — and content an agent has already read into a conversation is out of reach at this layer either way. + +### 5. Relaxing a path or manifest check in `skills-fs.ts` + +`keyRejectionReason` and `unsafePathReason` are shared by the write and prune paths so the two cannot disagree about which paths this SDK may destroy, and both are **non-relaxable**. The same goes for the manifest rules: a destructive operation is allowed only on a path the manifest lists under a matching key, and a corrupt manifest (including one larger than `MAX_MANIFEST_BYTES`, 8 MiB) suppresses every destructive action. Each has a dedicated abuse-case test in `src/__tests__/skills-fs.test.ts`; if one starts failing, the defense changed, not the test. + +Three specifics a later contributor is most likely to widen: + +- **The 22 Windows reserved device names** — `con`, `prn`, `aux`, `nul`, `com1`–`com9`, `lpt1`–`lpt9` — are rejected by `keyRejectionReason` on every platform. + - No `process.platform === 'win32'` gate: a root written by a Linux container and read from a Windows host is an ordinary deployment, and with no Windows CI runner a platform branch would be untested. + - Not in `isValidSkillKey` or `SKILL_KEY_PATTERN` either. `skillRefs` fails closed on a bad `skills` entry, so a grammar rejection would reject *every* skill reference for a customer who never touches Windows. A grammar that dropped the entry instead would shorten what `skillRefs` hands `writeSkills`, so prune deletes the skill's on-disk copy — "fails to write on Windows" becomes "deleted on Linux". The 255-byte path-component bound lives in this layer for the same reason. + - The set is exact: `com0` and `lpt0` are not reserved, and no case folding or suffix stripping is needed because the key grammar admits no uppercase, no `.`, and no `$`. +- **Adoption narrows the clobber refusal; it is not a hole in it.** A file at a managed path with no manifest entry is adopted (recorded, reported `skipped_current`) only when its on-disk sha256 equals the resolved content hash. That lets a reconcile killed between the content writes and the final manifest write heal instead of wedging. Adopt on anything weaker than an exact hash match and the guarantee is gone. `skipped_current` is reused because a new `ReconcileActionKind` member would break every consumer with an exhaustive `switch`. +- **`readRegularFile` is what makes that read safe**, and every part is load-bearing: + - `O_NONBLOCK`, because opening a FIFO with no writer blocks forever and would hang the reconcile and the event loop; + - `O_NOFOLLOW`; + - an `fstat` on the *handle*, not a `stat` on the path, refusing anything that is not a regular file. + + A failed read is a refusal, never a fall-through to the write. There is no `O_BINARY` (Node does no CRLF translation) — the one deliberate difference from the Python twin. + +### 6. Bypassing `fsOps` for a destructive filesystem call + +`safe-fs.ts` routes the final rename and the managed-file unlink through the `fsOps` record so tests can intercept exactly those two operations. Calling `fs.rename`/`fs.unlink` directly hides the operation from the atomicity and "no operation was attempted" assertions, which then pass vacuously. + +- The orphaned-temp sweep goes through `unlinkNoFollow` for the same reason, and takes its filename pattern from `tempNamePattern` in `safe-fs.ts` rather than a copy. The sweep may only unlink a file whose *name* marks it as one this SDK created, so two spellings of the rule would eventually miss orphans or remove something it did not write. +- A **third** hook is deliberately outside `fsOps`: the root-swap races mock `mkdir` and `open` through `vi.mock('node:fs/promises')`, because they must fire *before* the root is pinned. Folding them into `fsOps` would blur what a "no filesystem operation was attempted" assertion means. + +On the platform bound: Node has no `renameat`/`unlinkat`, so `SUPPORTS_DIR_FD` is `false` on every release to date. But `SUPPORTS_PROC_FD` addresses children through `/proc/self/fd//`, which the kernel resolves from the descriptor's inode rather than the name it was opened under. That **closes** the swap window on Linux, so the swap-race tests run there and skip only where neither capability exists. Two consequences: + +- The `(dev, ino)` identity re-check is the macOS floor only and must not run on the fast path: `lstat` of `/proc/self/fd/` reports procfs's magic symlink, not the directory. +- A green macOS run proves nothing here: all ten of those tests skip locally and run only on Linux CI. + +### 7. "Fixing" the platform bound, or adding a writability field to `ReconcileReport` + +Two decisions here look like unfinished work and are not. Do not reverse either without re-examining the threat model behind it. + +- **Windows reparse-point checks (`GetFileAttributesW`, `FILE_FLAG_OPEN_REPARSE_POINT`) are deliberately not implemented.** Windows is not a supported or tested platform for this release, there is no Windows CI runner, and Node has no `*at()` primitive that would make such checks meaningful: off the Linux fast path, the racy per-component `lstat` floor runs everywhere. The bound is documented in `safe-fs.ts` and the README. Keep the reserved-device-name check (it keeps a Linux-written root usable from Windows), but do not read it as Windows hardening. If Windows becomes supported, add the CI runner first, then revisit both together. +- **`ReconcileReport` must not grow a "managed root is writable" field.** The SDK knows only its *own* identity, which just wrote there, and cannot know which identity will run the agent, so any such field would create false confidence. The real mitigation is deployment-side: run the reconcile as a different identity than the agent, so the `0644`/`0755` modes stop a prompt-injected agent from rewriting its own instructions or the manifest. The operator's verification steps are in the README. + --- ## Adding a New Export 1. Implement the function/type in the appropriate `src/*.ts` file. -2. Add a named export to `src/index.ts`. -3. Rebuild: `yarn build` from this directory. +2. Add a named export to `src/index.ts` — or to `src/experimental.ts` if the feature is experimental. +3. Rebuild: `yarn build` from this directory, then `yarn api:update` (from this directory) and commit the changed `etc/*.api.md` report. 4. All handler packages pick up the change automatically via the local `file:../client` dependency. ## Invariants to Preserve @@ -255,5 +614,54 @@ When `enabled` is `false`, `config` is always `null`. When `enabled` is `true` b - Do not add dependencies on any `@launchdarkly/ai-*` handler package. This package has no upward dependencies. - Do not add a hard dependency on `@launchdarkly/node-server-sdk` or any other LD SDK. The node SDK must remain an optional peer, discovered via dynamic `import()`. - Handler packages and consumer code must import `LDContext` from `@launchdarkly/ai-server` — not directly from any LD SDK. The owned definition in `src/types.ts` is structurally compatible with all LD SDK versions. -- Do not weaken the `parseAiConfig` validation — handler packages rely on `config` being valid when they receive it. +- Do not weaken the `parseAiConfig` validation — handler packages rely on `config` being valid when they receive it. The `skills` array fails **closed**: one malformed reference fails the whole parse, rather than silently materializing a partial skill set. +- Do not interpret skill content anywhere — no frontmatter parsing, no YAML dependency, no encoding assumption beyond the one wire-string encode inside integrity verification. See pitfall 3. +- Skills telemetry goes through the private emitter seam in `skills-core.ts` and nowhere else. + - **Never** call `client.track()` for a skills operation: these are LaunchDarkly product signals, and `track()` would need an LD context, spend the customer's event volume, and land in their data export. + - Exactly three signal names exist (`AgentControl Skill Integrity Failure`, `AgentControl Skill Materialized`, `AgentControl Skill Revoked Received`), an allowlist, not a floor. `AgentControl Skill SDK Reference Returned` and `AgentControl Skill Content Retrieved` must **never** be emitted. + - Every signal is built by a `record*` function in `skills-core.ts`, so the allowlist is enforced in one place. +- No filesystem paths and no skill content in telemetry — hashes and byte counts only. Paths belong in the `ReconcileReport`, which is user-facing API. +- The `ld.skills.integrity_failure` log record is a **documented customer-facing contract**, not a debug line: + - built only by the `record*` recorders in `skills-core.ts` — `recordIntegrityFailure` for the eight verification failures, `recordKeyMismatch` and `recordVersionMismatch` for the two boundary ones; + - written to `console.error` on every failure, unconditionally, because it is the only detection surface a customer with telemetry off (or no reachable telemetry destination) has; + - keys inserted in **alphabetical order**, so `JSON.stringify` is byte-identical to the Python SDK's `json.dumps(record, sort_keys=True, separators=(",", ":"))` for the same failure, modulo `language`; do not reorder them; + - absent optional fields are omitted, never emitted as `null`. + + Renaming the event, renaming or dropping a field, or emitting a null is a breaking change to a security control customers alert on. +- `reason_code` is a **closed ten-token vocabulary**: `hash_mismatch`, `invalid_key`, `invalid_version`, `key_mismatch`, `missing_content`, `missing_content_hash`, `not_an_object`, `not_utf8`, `over_size_cap`, `version_mismatch`. + - Eight are one per `recordIntegrityFailure` call site. `key_mismatch` and `version_mismatch` come from `recordKeyMismatch` and `recordVersionMismatch` at the retrieval boundary and write the log record without the signal (see the outcome table below). + - The two boundary codes are reachable only after all eight verification checks pass, so they never coincide with a verification code. They can compete with each other; the key check runs first, because an answer that is not the requested skill makes its version moot. + - An eleventh token must land in the Python SDK, the README's `reason_code` table, and the vocabulary test in `src/__tests__/skills.test.ts` in the same change; a one-sided token silently breaks a customer's cross-language alert rule. + - `reason_code` stays **out** of telemetry properties; it belongs to the customer-owned log record, not LaunchDarkly's counter. +- `SkillOutcomeReason` is a **closed five-token vocabulary** — `absent`, `integrity_failure`, `ok`, `store_unavailable`, `wrong_version` — published by `getSkillResult`, branched on by customers, and identical in the Python SDK. + - A sixth token must land in the Python SDK, the README's `reason` table, and the union-exhaustiveness test in `src/__tests__/skills.test.ts` in the same change. + - It is coarser than `IntegrityReasonCode`: the ten integrity tokens say *which check failed* (for the operator, via the log record); the five outcome tokens say *what the caller got*. + - The version mismatch has a token in each, spelled differently on purpose — `version_mismatch` the code, `wrong_version` the outcome — so a detection rule is unambiguous about which surface it matches. Do not unify them. + - `verifyRawSkill` returns `null` without surfacing which integrity token fired, deliberately; the operator already has it from the log record. +- `Resolution.reason` is set **explicitly at every construction site**, never derived from the prose in `Resolution.error`. The field is required so a new internal outcome must pick a public token rather than inherit `'absent'` by omission. The mapping: + + | Where the resolution is built | `reason` | + |---|---| + | `resolveFromStore` — the store threw (also sets `unavailable`) | `store_unavailable` | + | `resolveFromStore` — `raw` is not an object (null, an array, a scalar) | `absent` | + | `resolveFromStore` — `verifyRawSkill` returned `null` | `integrity_failure` | + | `resolveFromStore` — `skill.key !== key` (the store answered under another key) | `integrity_failure` | + | `resolveFromStore` — `skill.version !== wantedVersion` (the store answered a pin with another version) | `wrong_version` | + | `resolveFromStore` — success | `ok` | + | `skills-fs.ts` `resolveReference` — deadline exhausted, or no store configured (both set `unavailable`) | `store_unavailable` | + + - **The two `integrity_failure` rows differ.** The `verifyRawSkill` row fires inside verification: the `AgentControl Skill Integrity Failure` signal **and** the `ld.skills.integrity_failure` log record. The key-mismatch row fires after verification passed, via `recordKeyMismatch`: **the log record only** (`reason_code: key_mismatch`, plus a `served_key` field no other record carries), no signal. + - **The `wrong_version` row also writes a record only**, via `recordVersionMismatch`: `reason_code: version_mismatch`, a record-only `served_version` field, and a `version` field carrying the version *requested*. `getSkill` collapses `wrong_version` to `null`, so without the record an operator on the default accessor could not see a store answering pins with the wrong version. + - **Why a record but no signal, for both boundary rows.** The record is the customer-owned detection path, and sharing the event identity lets one SIEM rule catch both, with the code as the discriminator. The usual cause is a broken custom store adapter, not an attacker, so LaunchDarkly's counter stays out — the same false positive the not-an-object row avoids by reading as `absent`. The two rows are one decision: a change that gives either a signal must justify it for both. Tests pin both directions for each row; do not emit the signal or drop the record. + - **Shipped stores never produce the mismatch rows.** `FDv2SkillStore` and `InMemorySkillStore` answer a pin with that version or nothing, so `wrong_version` needs a **hand-built** store that ignores its version argument. A test using a shipped store gets `absent` and silently stops covering the row. + - **Keep both checks in `resolveFromStore`.** `verifyRawSkill` is unary (is one object self-consistent?); a mismatch is relational (object vs. request). Threading an expected key or version into it would make the parameter optional at every call site, and a check that vanishes when a caller forgets an argument is worse than one at the single site where both values are in hand. + - **Adding a row** means answering "which of the five does a caller see?" before writing the code. `unavailable` stays a separate field: it is narrower, it is what suppresses pruning, and the `skills-fs.ts` row never reaches an accessor. +- `wantedVersion` is passed **into** `SkillStore.getObject(kind, key, version)`, and the post-hoc `skill.version !== wantedVersion` check is kept too. Removing either is wrong: + - The parameter lets the store choose among several held versions. Without it, a satisfiable pin reads as `wrong_version` and writes a `version_mismatch` record accusing a correct store. + - The check is a **defense**, because the store is untrusted. Without it, a lying store's answer gets through. + + Both shipped stores honour the parameter: a pin gets exactly that version, an omitted version gets the newest held. A pin that misses while well-formed versions exist is `absent`; a pin that matches nothing well-formed falls through to the version-less entry, so a malformed object reaches verification and is withheld with a signal instead of reading as a deletion. +- `getSkill`'s contract — "resolves to `null`, never rejects; throws only when no store is configured" — is **frozen** (documented in its JSDoc and the README). Callers treat that `null` as "no skill", so new reporting goes *alongside* it, as `getSkillResult` does. `getSkillResult` projects the same `Resolution` and shares the single throw; it records no telemetry and writes no log record of its own, because the integrity record already fired during verification. `getSkills` and `allSkills` have no reporting equivalents. +- This package has no logger abstraction; every `console.*` call site carries a `biome-ignore` saying so. A `logger` option would be a package-wide API decision, not a skills change. Until one exists, the integrity record must be self-describing in the string it logs, which is why the event name appears both in the prefix and in the JSON. +- Signal names, property keys, wire field names (`contentHash`), the manifest filename and format, and the exported constants are **identical strings** to the Python SDK. A polyglot fleet has to reconcile the same directory identically, so changing one of these is a cross-language breaking change. - `parseUsage` must continue to accept `input_tokens/output_tokens`, `inputTokens/outputTokens`, and `input/output` as all existing handlers return one of these variants. diff --git a/packages/client/api-extractor.experimental.json b/packages/client/api-extractor.experimental.json new file mode 100644 index 00000000..d6ae927a --- /dev/null +++ b/packages/client/api-extractor.experimental.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/temp/dts/experimental.d.ts", + "apiReport": { + "enabled": true, + "reportFileName": "ai-server-experimental", + "reportFolder": "/etc/", + "reportTempFolder": "/temp/" + }, + "docModel": { "enabled": false }, + "dtsRollup": { "enabled": false }, + "tsdocMetadata": { "enabled": false }, + "messages": { + "extractorMessageReporting": { + "ae-missing-release-tag": { "logLevel": "none" }, + "ae-unresolved-link": { "logLevel": "none" } + }, + "tsdocMessageReporting": { + "default": { "logLevel": "none" } + } + } +} diff --git a/packages/client/api-extractor.json b/packages/client/api-extractor.json new file mode 100644 index 00000000..d1fde370 --- /dev/null +++ b/packages/client/api-extractor.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/temp/dts/index.d.ts", + "apiReport": { + "enabled": true, + "reportFileName": "ai-server", + "reportFolder": "/etc/", + "reportTempFolder": "/temp/" + }, + "docModel": { "enabled": false }, + "dtsRollup": { "enabled": false }, + "tsdocMetadata": { "enabled": false }, + "messages": { + "extractorMessageReporting": { + "ae-missing-release-tag": { "logLevel": "none" }, + "ae-unresolved-link": { "logLevel": "none" } + }, + "tsdocMessageReporting": { + "default": { "logLevel": "none" } + } + } +} diff --git a/packages/client/etc/ai-server-experimental.api.md b/packages/client/etc/ai-server-experimental.api.md new file mode 100644 index 00000000..ccb68fed --- /dev/null +++ b/packages/client/etc/ai-server-experimental.api.md @@ -0,0 +1,234 @@ +## API Report File for "@launchdarkly/ai-server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public +export function allSkills(): Promise; + +// @public +export function createSkill(init: { + key: string; + version: number; + content: Uint8Array; + contentHash: string; + name?: string | null; + description?: string | null; +}): Skill; + +// @public +export function createSkillOutcome(init: { + skill?: Skill | null; + reason: SkillOutcomeReason; + detail?: string | null; +}): SkillOutcome; + +// @public +export function createSkillReference(init: { + key: string; + version: number; +}): SkillReference; + +// @public (undocumented) +export type FDv2Mode = 'stream' | 'poll'; + +// @public +export class FDv2SkillStore implements SkillStore { + constructor(sdkKey: string, options?: FDv2SkillStoreOptions); + addListener(kind: string, fn: (raw: RawSkillObject) => unknown): void; + // (undocumented) + allObjects(kind: string): Record; + _backoffResetIntervalMs: number; + close(): Promise; + get diagnostics(): StoreDiagnostics; + get failed(): string | null; + // (undocumented) + getObject(kind: string, key: string, version?: number | null): RawSkillObject | null; + isInitialized(): boolean; + removeListener(kind: string, fn: (raw: RawSkillObject) => unknown): void; + start(): this; + waitForSkills(options?: { + readonly timeoutSeconds?: number; + }): Promise; +} + +// @public (undocumented) +export type FDv2SkillStoreOptions = { + readonly mode?: FDv2Mode; + readonly baseUri?: string; + readonly streamUri?: string; + readonly pollIntervalSeconds?: number; + readonly readTimeoutSeconds?: number; + readonly initialBackoffSeconds?: number; + readonly maxBackoffSeconds?: number; + readonly requester?: Requester; +}; + +// @public +export function getSkill(key: string, options?: { + version?: number; +}): Promise; + +// @public +export function getSkillResult(key: string, options?: { + version?: number; +}): Promise; + +// @public +export function getSkills(refs: ReadonlyArray): Promise; + +// @public +export class InMemorySkillStore implements SkillStore { + constructor(objects?: Record); + addListener(kind: string, fn: (raw: RawSkillObject) => unknown): void; + allObjects(kind: string): Record; + getObject(kind: string, key: string, version?: number | null): RawSkillObject | null; + put(raw: RawSkillObject): void; + removeListener(kind: string, fn: (raw: RawSkillObject) => unknown): void; +} + +// @public +export const MANIFEST_FILENAME = ".launchdarkly-skills.json"; + +// @public +export const MANIFEST_VERSION = 1; + +// @public +export type OnUnavailable = 'keep' | 'raise'; + +// @public +export type RawSkillObject = { + key?: unknown; + version?: unknown; + content?: unknown; + contentHash?: unknown; + name?: unknown; + description?: unknown; + [field: string]: unknown; +}; + +// @public +export type ReconcileAction = { + readonly key: string; + readonly action: ReconcileActionKind; + readonly version: number | null; + readonly path: string | null; + readonly error: string | null; +}; + +// @public +export type ReconcileActionKind = 'written' | 'updated' | 'skipped_current' | 'removed' | 'error'; + +// @public +export type ReconcileReport = { + readonly actions: readonly ReconcileAction[]; + readonly ok: boolean; + readonly errors: readonly ReconcileAction[]; +}; + +// @public +export function setSkillStore(store: SkillStore | null | undefined): void; + +// @public +export type Skill = { + readonly key: string; + readonly version: number; + readonly content: Uint8Array; + readonly contentHash: string; + readonly name: string | null; + readonly description: string | null; +}; + +// @public +export const SKILL_FILENAME = "SKILL.md"; + +// @public +export type SkillOutcome = { + readonly skill: Skill | null; + readonly reason: SkillOutcomeReason; + readonly detail: string | null; +}; + +// @public +export type SkillOutcomeReason = 'absent' | 'integrity_failure' | 'ok' | 'store_unavailable' | 'wrong_version'; + +// @public +export type SkillReference = { + readonly key: string; + readonly version: number; +}; + +// Warning: (ae-forgotten-export) The symbol "AiConfigRep" needs to be exported by the entry point experimental.d.ts +// +// @public +export function skillRefs(config: AiConfigRep | null | undefined): SkillReference[]; + +// @public +export const SKILLS_DEFAULT_BASE_URI = "https://sdk.launchdarkly.com"; + +// @public +export const SKILLS_DEFAULT_DEBOUNCE_MS = 500; + +// @public +export const SKILLS_DEFAULT_STREAM_URI = "https://stream.launchdarkly.com"; + +// @public +export type SkillStore = { + getObject(kind: string, key: string, version?: number | null): RawSkillObject | null | undefined; + allObjects(kind: string): Record; + addListener?(kind: string, fn: (raw: RawSkillObject) => unknown): void; + removeListener?(kind: string, fn: (raw: RawSkillObject) => unknown): void; + isInitialized?(): boolean; +}; + +// @public +export class SkillWatcher { + constructor(store: SkillStore, request: ReadonlyArray | '*', root: string, options: WriteSkillsOptions, debounceMs: number, onReconcile?: ((report: ReconcileReport) => unknown) | undefined); + close(): Promise; + readonly notify: () => void; + get reconciles(): number; + runInitial(): Promise; +} + +// @public +export type StoreDiagnostics = { + readonly payloadsTransferred: number; + readonly skillObjectsReceived: number; + readonly objectsIgnored: number; + readonly objectsRevoked: number; + readonly payloadsIgnored: number; + readonly hashlessObjects: number; + readonly connectionFailures: number; + readonly lastError: string | null; +}; + +// @public +export function watchSkills(skills: ReadonlyArray | '*', root: string, options?: WatchSkillsOptions): Promise<{ + report: ReconcileReport; + watcher: SkillWatcher; +}>; + +// @public +export type WatchSkillsOptions = WriteSkillsOptions & { + debounceMs?: number; + onReconcile?: (report: ReconcileReport) => unknown; +}; + +// @public +export function writeSkills(skills: ReadonlyArray | '*', root: string, options?: WriteSkillsOptions): Promise; + +// @public +export type WriteSkillsOptions = { + prune?: boolean; + timeout?: number; + onUnavailable?: OnUnavailable; +}; + +// Warnings were encountered during analysis: +// +// temp/dts/skills-fdv2.d.ts:454:5 - (ae-forgotten-export) The symbol "Requester" needs to be exported by the entry point experimental.d.ts + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/client/etc/ai-server.api.md b/packages/client/etc/ai-server.api.md new file mode 100644 index 00000000..6b9ff61e --- /dev/null +++ b/packages/client/etc/ai-server.api.md @@ -0,0 +1,695 @@ +## API Report File for "@launchdarkly/ai-server" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Context } from '@opentelemetry/api'; +import { Span } from '@opentelemetry/api'; + +// @public +export function addCachedTokensToInput(rawUsage: Record): SpanUsage; + +// @public (undocumented) +export type AiConfigRep = { + instructions?: string; + messages?: ConfigMessage[]; + model: { + name: string; + region?: string; + parameters?: Record; + custom?: Record; + }; + tools?: Record; + judgeConfiguration?: { + judges?: Array<{ + key: string; + samplingRate: number; + }>; + }; + evaluationMetricKey?: string; + provider: { + name: string; + }; + outputFormat?: Record; + skills?: unknown; +}; + +// @public +export function anyMultimodal(turns: ReadonlyArray<{ + content: MessageContent; +}>): boolean; + +// Warning: (ae-forgotten-export) The symbol "BuildJudgeTasksArgs" needs to be exported by the entry point index.d.ts +// +// @public +export const buildJudgeTasks: (args: BuildJudgeTasksArgs) => Promise; + +// @public +export function camelizeModelParameters(parameters: Record): Record; + +// @public +export type CanonicalTurn = { + role: 'user' | 'assistant'; + content: MessageContent; +}; + +// @public +export function collapseMessagesToInstructions(config: AiConfigRep): AiConfigRep; + +// @public +export function compose(a: Registry, b: Registry): Registry; + +// @public +export function composeHistory(opts: { + history: Message[]; + userInput?: string; + configMessages?: ConfigTurn[]; +}): CanonicalTurn[]; + +// @public (undocumented) +export const config: (args: ConfigArgs) => { + invoke: (userInput: string | undefined, context: LDContext, variables?: Record, history?: Message[]) => Promise>; + stream: (userInput: string | undefined, context: LDContext, variables?: Record, history?: Message[]) => AsyncGenerator; +}; + +// @public +export type ConfigArgs = { + key: string; + handler?: ProviderHandler | ProviderHandler[]; + toolHandlers?: Record; + registry?: RegistryInput; + skipJudges?: boolean; +}; + +// Warning: (ae-internal-missing-underscore) The name "configInternal" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export const configInternal: (input: ConfigArgs) => { + invoke: (userInput: string | undefined, context: LDContext, variables?: Record, history?: Message[]) => Promise>; + stream: (userInput: string | undefined, context: LDContext, variables?: Record, history?: Message[]) => AsyncGenerator; +}; + +// @public +export type ConfigMessage = { + role: 'user' | 'assistant' | 'system'; + content: string; +}; + +// @public +export type ConfigTurn = { + role: 'user' | 'assistant'; + content: string; +}; + +// @public +export type ContentBlock = TextContentBlock | ImageContentBlock; + +// @public +export interface ContentCaptureOptions { + captureContent?: boolean; +} + +// @public +export function contentToText(content: MessageContent): string; + +// @public +export class ConversationIdSpanProcessor { + // (undocumented) + forceFlush(): Promise; + // (undocumented) + onEnd(_span: unknown): void; + // (undocumented) + onStart(span: Span, parentContext?: Context): void; + // (undocumented) + shutdown(): Promise; +} + +// Warning: (ae-forgotten-export) The symbol "HandlerInput" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "StreamHandlerInput" needs to be exported by the entry point index.d.ts +// +// @public +export function createHandler(providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, streamHandler?: StreamHandlerInput, captureContent?: boolean): ProviderHandler; + +// Warning: (ae-internal-missing-underscore) The name "createHandlerInternal" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export function createHandlerInternal(providesFor: [string, 'agent' | 'messages'], handler: HandlerInput, streamHandler?: StreamHandlerInput, captureContent?: boolean): ProviderHandler; + +// @public +export function createRunUsage(): RunUsage; + +// @public +export function endSpanOnce(span: Span, tracker: Set, abandoned?: boolean): void; + +// @public (undocumented) +export function getClient(): LDClientInterface; + +// @public (undocumented) +export const globalRegistry: Registry; + +// Warning: (ae-forgotten-export) The symbol "GraphCaller" needs to be exported by the entry point index.d.ts +// +// @public +export const graph: (key: string, options: GraphOptions) => GraphCaller; + +// @public +export type GraphArgs = GraphOptions & { + context: LDContext; +}; + +// @public +export type GraphDefinition = { + key: string; + enabled: boolean; + root: GraphNode | null; + getNode: (key: string) => GraphNode | undefined; + getChildNodes: (key: string) => GraphNode[]; + getParentNodes: (key: string) => GraphNode[]; + terminalNodes: () => GraphNode[]; + edgesFrom: (key: string) => GraphEdge[]; + runNode: (node: GraphNode, input?: string, opts?: RunNodeOptions) => Promise; + route: (node: GraphNode, input?: string, opts?: RunNodeOptions) => Promise; + traverse: (fn: TraverseVisitor, ctx?: Record) => Promise; + reverseTraverse: (fn: TraverseVisitor, ctx?: Record) => Promise; +}; + +// @public +export type GraphEdge = { + key: string; + sourceKey: string; + targetKey: string; + handoff?: Record; +}; + +// Warning: (ae-internal-missing-underscore) The name "graphInternal" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export const graphInternal: (key: string, options: GraphOptions) => GraphCaller; + +// @public +export type GraphNode = { + key: string; + config: AiConfigRep; + meta: LDVariationMeta; + edges: GraphEdge[]; + isTerminal: () => boolean; +}; + +// @public +export type GraphOptions = { + handlers?: ProviderHandler[]; + toolHandlers?: Record; + graphJudge?: string; + registry?: RegistryInput; +}; + +// @public +export type GraphStreamEvent = { + type: 'node_start'; + nodeKey: string; +} | { + type: 'chunk'; + text: string; + nodeKey: string; +} | { + type: 'node_done'; + nodeKey: string; + response: string; + usage: TokenUsage; +} | { + type: 'handoff'; + sourceKey: string; + targetKey: string; +} | { + type: 'done'; + response: string; + usage: TokenUsage; + judgeResults?: ProviderResponse['judgeResults']; +}; + +// @public +export type GraphTopology = { + root: string; + edges?: Record; + }>>; +}; + +// @public +export const GraphTopologySchema: { + safeParse: typeof parseGraphTopology; + parse(raw: unknown): GraphTopology; +}; + +// @public +export type HandlerStreamEvent = { + type: 'chunk'; + text: string; +} | { + type: 'done'; + output?: string; + usage: Record; +}; + +// @public +export function hasMultimodalContent(content: MessageContent): boolean; + +// @public +export function imageBlockToUrl(block: ImageContentBlock): string; + +// @public +export type ImageContentBlock = { + type: 'image'; + source: { + type: 'base64'; + media_type: string; + data: string; + }; +} | { + type: 'image'; + source: { + type: 'url'; + url: string; + }; +}; + +// Warning: (ae-forgotten-export) The symbol "InitBaseClientOptions" needs to be exported by the entry point index.d.ts +// +// @public +export function initClient(client: LDClientInterface, options?: InitBaseClientOptions): Promise; + +// @public (undocumented) +export function initClient(options?: InitBaseClientOptions): Promise; + +// @public +export function inspectConfig(key: string, context: LDContext): Promise; + +// Warning: (ae-internal-missing-underscore) The name "inspectConfigInternal" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal +export function inspectConfigInternal(key: string, context: LDContext): Promise; + +// @public +export type InspectConfigResult = { + enabled: boolean; + config: AiConfigRep | null; + meta: LDVariationMeta | null; +}; + +// @public +export function isContentBlocks(content: MessageContent): content is ContentBlock[]; + +// @public +export type JudgeCallResult = { + score: number; + response: string; + usage: { + total: number; + input: number; + output: number; + }; +}; + +// @public +export type JudgeRunResult = JudgeCallResult & { + trackData: TrackData & { + judgeConfigKey: string; + }; +}; + +// @public +export type JudgeTask = { + configKey: string; + judgeConfig: AiConfigRep; + judgeMeta: LDVariationMeta; + actualOutput: string; + userContext: LDContext; + variables?: Record; + judgeProvider: string | undefined; + judgeMode: 'agent' | 'messages'; + collapseMessages: boolean; + evaluationMetricKey?: string; + parentTrackData: TrackData; +}; + +// @public +export function langChainContentText(content: unknown): string; + +// @public +export function langChainFinishReasons(source: unknown): string[] | undefined; + +// @public +export function langChainSpanMessages(messages: ReadonlyArray): { + systemInstructions?: string; + messages: SpanMessage[]; +}; + +// @public +export function langChainSpanUsage(usage: Record | undefined): SpanUsage | undefined; + +// @public (undocumented) +export interface LDClientInterface { + // (undocumented) + close(): Promise; + // (undocumented) + flush(): Promise; + // (undocumented) + track(eventName: string, context: LDContext, data?: unknown, metricValue?: number): void; + // (undocumented) + variation(key: string, context: LDContext, defaultValue: unknown): Promise; +} + +// @public (undocumented) +export type LDContext = LDUser | LDSingleKindContext | LDMultiKindContext; + +// @public (undocumented) +export type LDMultiKindContext = { + kind: 'multi'; + [kind: string]: LDSingleKindContext | 'multi'; +}; + +// @public (undocumented) +export type LDSingleKindContext = { + kind: string; + key: string; + anonymous?: boolean; + name?: string; + [attribute: string]: unknown; +}; + +// @public @deprecated (undocumented) +export type LDUser = { + key: string; + anonymous?: boolean; + name?: string; + [attribute: string]: unknown; +}; + +// @public (undocumented) +export type LDVariationMeta = { + enabled?: boolean; + variationKey?: string; + version?: number; + mode?: 'agent' | 'completion' | 'judge'; + modelKey?: string; + modelVersion?: number; +}; + +// @public +export const makeNodeTrackData: (node: GraphNode, graphKey: string, runId: string) => TrackData; + +// @public +export const makeRunTrackData: (input: { + configKey: string; + config: AiConfigRep; + meta: LDVariationMeta | null | undefined; + graphKey?: string; +}) => TrackData; + +// @public +export type Message = { + role: 'user' | 'assistant' | 'system'; + content: MessageContent; +}; + +// @public +export type MessageContent = string | ContentBlock[]; + +// @public +export const NATIVE_TOOL_KEY: unique symbol; + +// @public +export class NativeTool { + constructor(id: symbol, + toolName: string); + // (undocumented) + readonly id: symbol; + readonly toolName: string; +} + +// @public +export function normalizeModelParameters(parameters: unknown): Record; + +// @public +export const omitModelStamps: (trackData: T) => Omit; + +// @public +export function parseJSONWithPossibleFences(rawText: string): T | null; + +// @public +export function parseTemplate(template: string, variables: Record): string; + +// @public +export function pickForwardedModelParameters(parameters: Record | undefined, keys: ReadonlyArray): Record; + +// @public +export type ProviderGraphResponse = { + response: string; + usage: TokenUsage; + judgeResults?: ProviderResponse['judgeResults']; +}; + +// @public (undocumented) +export type ProviderHandler = ((config: AiConfigRep, userInput?: string, toolHandlers?: Record, variables?: Record, history?: Message[]) => Promise<{ + output?: unknown; + usage?: Record; +}>) & { + providesFor?: [provider: string, type: 'agent' | 'messages']; + captureContent?: boolean; + stream?: (config: AiConfigRep, userInput?: string, toolHandlers?: Record, variables?: Record, history?: Message[]) => AsyncGenerator; +}; + +// @public (undocumented) +export type ProviderResponse = { + response: T; + usage: TokenUsage; + judgeResults?: Record; + judgeTasks?: JudgeTask[]; + trackData: TrackData; +}; + +// @public (undocumented) +export type ProviderSetupFn = () => ProviderHandler; + +// @public +export function registerAiSdkPackage(name: string, version: string): void; + +// @public (undocumented) +export class Registry { + constructor(config?: { + handlers?: ProviderHandler[]; + tools?: Record; + }); + // (undocumented) + handlers: ProviderHandler[]; + // (undocumented) + register(config: { + handlers?: ProviderHandler[]; + tools?: Record; + }): void; + // (undocumented) + tools: Record; +} + +// @public (undocumented) +export type RegistryInput = Registry; + +// @public +export function reportUsage(helper: string): void; + +// @public (undocumented) +export function reportUsage(helper: string, packageName: string, packageVersion: string): void; + +// @public +export const resolveGraph: (key: string, options: GraphArgs) => Promise; + +// @public +export type RouteResult = ProviderResponse & { + next?: GraphNode; +}; + +// @public +export const runJudge: (task: JudgeTask, handlers: ProviderHandler[]) => Promise; + +// @public +export type RunNodeOptions = { + variables?: Record; + toolHandlers?: Record; + from?: GraphNode; + history?: Message[]; +}; + +// @public +export interface RunUsage { + add(turn: SpanUsage | undefined): void; + readonly reported: boolean; + // (undocumented) + readonly total: SpanUsage; +} + +// @public +export function setConversationIdIfAbsent(span: Span, id: string): void; + +// @public +export function setInputContentAttributes(span: Span, capture: boolean, input: { + systemInstructions?: string; + messages?: SpanMessage[]; + toolDefinitions?: ReadonlyArray; +}): void; + +// @public +export function setLdSpanAttributes(span: Span, variables: Record | undefined): void; + +// @public +export function setModelIdentityAttributes(span: Span, providerName: string, requestModel: string, legacySystem?: string): void; + +// @public +export function setOutputContentAttributes(span: Span, capture: boolean, messages: SpanMessage[]): void; + +// @public +export function setToolCallContentAttributes(span: Span, capture: boolean, io: { + arguments?: unknown; + result?: unknown; +}): void; + +// @public +export function setToolDefinitionAttributes(span: Span, capture: boolean, tools: ReadonlyArray): void; + +// @public +export function setUsageSpanAttributes(span: Span, usage: SpanUsage): void; + +// @public (undocumented) +export function shutdown(): Promise; + +// @public +export function shutdownTelemetry(): Promise; + +// @public +export interface SpanMessage { + finishReason?: string; + // (undocumented) + parts: SpanMessagePart[]; + // (undocumented) + role: 'system' | 'user' | 'assistant' | 'tool' | (string & {}); +} + +// @public +export type SpanMessagePart = { + type: 'text'; + content: string; +} | { + type: 'reasoning'; + content: string; +} | { + type: 'tool_call'; + id?: string; + name: string; + arguments?: unknown; +} | { + type: 'tool_call_response'; + id?: string; + result?: unknown; +}; + +// @public +export type SpanUsage = { + input: number; + output: number; + cacheRead: number; + cacheCreation: number; +}; + +// @public +export type StreamEvent = { + type: 'chunk'; + text: string; +} | { + type: 'done'; + response: string; + usage: TokenUsage; + judgeResults?: ProviderResponse['judgeResults']; +}; + +// @public +export type TextContentBlock = { + type: 'text'; + text: string; +}; + +// @public +export const textMessage: (role: SpanMessage["role"], content: string) => SpanMessage; + +// @public (undocumented) +export type TokenUsage = { + input: number; + output: number; + total: number; + inputDetails?: { + uncached: number; + cacheRead: number; + cacheCreation: number; + }; +}; + +// @public (undocumented) +export type Tool = { + name: string; + parameters: Record; + type: 'function'; + customParameters?: Record; + description?: string; +}; + +// @public +export interface ToolDefinitionInput { + // (undocumented) + description?: string; + // (undocumented) + name: string; + // (undocumented) + parameters?: unknown; +} + +// @public +export type ToolHandlerFn = (...args: never) => unknown; + +// @public +export function toSemconvFinishReason(raw: string | null | undefined): string | undefined; + +// @public +export type TrackData = { + runId: string; + configKey: string; + variationKey: string; + version: number; + modelName: string; + providerName: string; + modelKey?: string; + modelVersion?: number; + graphKey?: string; + toolKey?: string; + judgeConfigKey?: string; + environmentId?: string; +}; + +// @public +export type TraverseVisitor = (node: GraphNode, ctx: Record) => T | Promise; + +// @public +export function waitForTelemetry(timeoutMs?: number): Promise; + +// @public +export function withConversationId(id: string, fn: () => T): T; + +// Warnings were encountered during analysis: +// +// temp/dts/types.d.ts:370:5 - (ae-forgotten-export) The symbol "parseGraphTopology" needs to be exported by the entry point index.d.ts + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/client/package.json b/packages/client/package.json index ce801435..c971581b 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -27,6 +27,13 @@ "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", + "typesVersions": { + "*": { + "experimental": [ + "dist/experimental.d.ts" + ] + } + }, "exports": { ".": { "import": { @@ -37,6 +44,16 @@ "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } + }, + "./experimental": { + "import": { + "types": "./dist/experimental.d.ts", + "default": "./dist/experimental.js" + }, + "require": { + "types": "./dist/experimental.d.cts", + "default": "./dist/experimental.cjs" + } } }, "files": [ @@ -48,7 +65,9 @@ "build:watch": "tsup-node --watch", "clean": "rm -rf dist", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "api:check": "tsc --emitDeclarationOnly --declarationMap false --outDir temp/dts && api-extractor run --config api-extractor.json && api-extractor run --config api-extractor.experimental.json", + "api:update": "tsc --emitDeclarationOnly --declarationMap false --outDir temp/dts && api-extractor run --local --config api-extractor.json && api-extractor run --local --config api-extractor.experimental.json" }, "dependencies": { "@opentelemetry/api": "^1.9.1", @@ -92,6 +111,7 @@ }, "devDependencies": { "@launchdarkly/node-server-sdk": "^9.7.3", + "@microsoft/api-extractor": "^7.59.4", "@opentelemetry/context-async-hooks": "^2.8.0", "@opentelemetry/core": "^2.8.0", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", diff --git a/packages/client/src/__tests__/client.test.ts b/packages/client/src/__tests__/client.test.ts index b3793bce..2b20c140 100644 --- a/packages/client/src/__tests__/client.test.ts +++ b/packages/client/src/__tests__/client.test.ts @@ -276,7 +276,7 @@ describe('config() — single handler', () => { expect(result.response).toBe('{"name":"Alice"}'); }); - it('returns raw string when outputFormat is set but output cannot be parsed as JSON (§3.10 best-effort)', async () => { + it('returns raw string when outputFormat is set but output cannot be parsed as JSON (best-effort)', async () => { const outputFormat = { type: 'object', properties: { name: { type: 'string' } } }; (extractVariation as ReturnType).mockResolvedValue({ config: { ...mockConfig, outputFormat }, diff --git a/packages/client/src/__tests__/lifecycle.test.ts b/packages/client/src/__tests__/lifecycle.test.ts index 0da414fa..888977a5 100644 --- a/packages/client/src/__tests__/lifecycle.test.ts +++ b/packages/client/src/__tests__/lifecycle.test.ts @@ -8,20 +8,34 @@ const mockFlush = vi.fn().mockResolvedValue(undefined); const mockClose = vi.fn().mockResolvedValue(undefined); const mockWaitForInitialization = vi.fn().mockResolvedValue(undefined); const mockLdInit = vi.fn(); +const ldSdk = vi.hoisted(() => ({ installed: true })); vi.mock('@launchdarkly/node-server-sdk', () => ({ - init: (...args: any[]) => mockLdInit(...args), + // A getter, so a test can make the package look uninstalled to this module + // instance: `initBaseClient` destructures `init` inside its import try/catch. + get init() { + if (!ldSdk.installed) throw new Error('Cannot find module @launchdarkly/node-server-sdk'); + return (...args: any[]) => mockLdInit(...args); + }, })); const mockTracerProviderShutdown = vi.fn().mockResolvedValue(undefined); -const mockTracerProviderRegister = vi.fn(); +const tracerProviders: object[] = []; -vi.mock('@opentelemetry/sdk-trace-node', () => ({ - NodeTracerProvider: class { - register = mockTracerProviderRegister; - shutdown = mockTracerProviderShutdown; - }, -})); +// A function declaration, so the tests that make the package look uninstalled +// can restore this mock afterwards; `vi.doUnmock` would hand every later test +// the real provider. +function mockNodeTracerProviderModule() { + return { + NodeTracerProvider: class { + shutdown = mockTracerProviderShutdown; + constructor() { + tracerProviders.push(this); + } + }, + }; +} +vi.mock('@opentelemetry/sdk-trace-node', mockNodeTracerProviderModule); vi.mock('@opentelemetry/sdk-trace-base', () => ({ BatchSpanProcessor: class {}, @@ -46,9 +60,11 @@ vi.mock('@opentelemetry/otlp-exporter-base', () => ({ })); const mockContextManagerEnable = vi.fn(); +const mockContextManagerDisable = vi.fn(); vi.mock('@opentelemetry/context-async-hooks', () => ({ AsyncLocalStorageContextManager: class { enable = mockContextManagerEnable; + disable = mockContextManagerDisable; }, })); @@ -58,13 +74,37 @@ vi.mock('@opentelemetry/core', () => ({ W3CTraceContextPropagator: class {}, })); +// OTel's process globals, modelled as the real API behaves: each registration is +// refused while something holds that global, until `disable()` releases it. +const otel = vi.hoisted(() => { + const held: Record<'trace' | 'context' | 'propagation', unknown> = { trace: null, context: null, propagation: null }; + const api = (name: keyof typeof held) => ({ + set: vi.fn((value: unknown) => { + if (held[name] != null) return false; + held[name] = value; + return true; + }), + disable: vi.fn(() => { + held[name] = null; + }), + }); + return { held, trace: api('trace'), context: api('context'), propagation: api('propagation') }; +}); + vi.mock('@opentelemetry/api', () => ({ createContextKey: (name: string) => Symbol(name), trace: { getTracerProvider: vi.fn().mockReturnValue({ _delegate: {} }), + setGlobalTracerProvider: otel.trace.set, + disable: otel.trace.disable, + }, + context: { + setGlobalContextManager: otel.context.set, + disable: otel.context.disable, }, propagation: { - setGlobalPropagator: vi.fn(), + setGlobalPropagator: otel.propagation.set, + disable: otel.propagation.disable, }, })); @@ -95,9 +135,16 @@ describe('lifecycle', () => { resetAiSdkInfo({ clearKnown: true }); vi.clearAllMocks(); delete process.env.LD_SDK_KEY; + otel.held.trace = otel.held.context = otel.held.propagation = null; + tracerProviders.length = 0; + ldSdk.installed = true; }); - afterEach(() => { + afterEach(async () => { + // `clearSingleton` bypasses `shutdown()`, so drop the module's provider too: + // a later test's setup would otherwise reuse it rather than build its own. + const { shutdownTelemetry } = await import('../lifecycle.js'); + await shutdownTelemetry(); clearSingleton(); delete process.env.LD_SDK_KEY; }); @@ -212,30 +259,36 @@ describe('lifecycle', () => { expect(mockOTLPTraceExporter).toHaveBeenCalledWith(expect.objectContaining({ compression: 'gzip' })); }); - it('registers AsyncLocalStorageContextManager via tracerProvider.register', async () => { - const mockClient = makeMockClient(); - mockLdInit.mockReturnValue(mockClient); + it('registers the tracer provider as the global one', async () => { + mockLdInit.mockReturnValue(makeMockClient()); process.env.LD_SDK_KEY = 'sdk-test-key'; const { initClient } = await import('../lifecycle.js'); await initClient(); - expect(mockTracerProviderRegister).toHaveBeenCalledWith( - expect.objectContaining({ contextManager: expect.objectContaining({ enable: expect.any(Function) }) }), - ); + expect(tracerProviders).toHaveLength(1); + expect(otel.held.trace).toBe(tracerProviders[0]); }); - it('registers W3C propagators via tracerProvider.register', async () => { - const mockClient = makeMockClient(); - mockLdInit.mockReturnValue(mockClient); + it('registers an enabled AsyncLocalStorageContextManager', async () => { + mockLdInit.mockReturnValue(makeMockClient()); process.env.LD_SDK_KEY = 'sdk-test-key'; const { initClient } = await import('../lifecycle.js'); await initClient(); - expect(mockTracerProviderRegister).toHaveBeenCalledWith( - expect.objectContaining({ propagator: expect.any(Object) }), - ); + expect(otel.context.set).toHaveBeenCalledWith(expect.objectContaining({ enable: mockContextManagerEnable })); + expect(mockContextManagerEnable).toHaveBeenCalledOnce(); + }); + + it('registers W3C propagators', async () => { + mockLdInit.mockReturnValue(makeMockClient()); + process.env.LD_SDK_KEY = 'sdk-test-key'; + + const { initClient } = await import('../lifecycle.js'); + await initClient(); + + expect(otel.propagation.set).toHaveBeenCalledWith(expect.any(Object)); }); it('sets up telemetry when a pre-initialized client is passed (BYOC path)', async () => { @@ -247,7 +300,58 @@ describe('lifecycle', () => { }; const { initClient } = await import('../lifecycle.js'); await initClient(byocClient); - expect(mockTracerProviderRegister).toHaveBeenCalled(); + expect(otel.held.trace).toBe(tracerProviders[0]); + }); + + it('passes the BYOC overload options through to telemetry setup', async () => { + // The second argument is the same options bag as the other overload, so + // `otlpEndpoint` must reach the exporter rather than being dropped. + const byocClient = { + variation: vi.fn(), + track: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; + const { initClient } = await import('../lifecycle.js'); + await initClient(byocClient, { otlpEndpoint: 'https://otlp.example.test/' }); + expect(mockOTLPTraceExporter).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://otlp.example.test/v1/traces' }), + ); + }); + + it('warns about an unrecognized option on both overloads', async () => { + // A misspelt or retired option would otherwise be dropped silently. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const { initClient } = await import('../lifecycle.js'); + await initClient(makeMockClient(), { serviceName: 'svc', sdkkey: 'sdk-x' } as never); + await initClient({ zeta: 1, alpha: 2 } as never); + expect(warn.mock.calls).toEqual([ + ['[LaunchDarkly] Ignoring unrecognized initClient option(s): sdkkey'], + ['[LaunchDarkly] Ignoring unrecognized initClient option(s): alpha, zeta'], + ]); + } finally { + warn.mockRestore(); + } + }); + + it('does not warn about documented options', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + const { initClient } = await import('../lifecycle.js'); + await initClient(makeMockClient(), { + sdkKey: 'sdk-x', + baseUri: 'https://base.example', + streamUri: 'https://stream.example', + eventsUri: 'https://events.example', + serviceName: 'svc', + environment: 'test', + otlpEndpoint: 'https://otlp.example', + }); + expect(warn).not.toHaveBeenCalledWith(expect.stringMatching(/unrecognized initClient option/)); + } finally { + warn.mockRestore(); + } }); it('is idempotent — calls init only once when called twice', async () => { @@ -261,6 +365,109 @@ describe('lifecycle', () => { expect(mockLdInit).toHaveBeenCalledOnce(); }); + it('retries after a failed init instead of replaying the rejection', async () => { + // A rejection must not be cached: an app whose first call ran before its + // key was available would otherwise fail for the life of the process. + const mockClient = makeMockClient(); + mockLdInit.mockReturnValue(mockClient); + + const { initClient, getClient } = await import('../lifecycle.js'); + await expect(initClient()).rejects.toThrow(/LD_SDK_KEY/); + + await expect(initClient({ sdkKey: 'late-key' })).resolves.toBe(mockClient); + expect(mockLdInit).toHaveBeenCalledOnce(); + expect(mockLdInit).toHaveBeenCalledWith('late-key', expect.anything()); + expect(getClient()).toBe(mockClient); + }); + + it('rejects every concurrent caller of a failed init, then retries', async () => { + const { initClient } = await import('../lifecycle.js'); + const results = await Promise.allSettled([initClient(), initClient()]); + expect(results.map((r) => r.status)).toEqual(['rejected', 'rejected']); + + mockLdInit.mockReturnValue(makeMockClient()); + process.env.LD_SDK_KEY = 'test-key'; + await initClient(); + expect(mockLdInit).toHaveBeenCalledOnce(); + }); + + it('closes the client it built when initialization fails, and retries with a new one', async () => { + // Retrying a timed-out init would otherwise leak a streaming connection + // per attempt. + const failed = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + failed.waitForInitialization = vi.fn().mockRejectedValue(new Error('timeout')); + const succeeded = makeMockClient(); + mockLdInit.mockReturnValueOnce(failed).mockReturnValueOnce(succeeded); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, getClient } = await import('../lifecycle.js'); + await expect(initClient()).rejects.toThrow('timeout'); + expect(failed.close).toHaveBeenCalledOnce(); + + await initClient(); + expect(mockLdInit).toHaveBeenCalledTimes(2); + expect(getClient()).toBe(succeeded); + }); + + it('reports the init failure even when closing the failed client also throws', async () => { + const failed = { ...makeMockClient(), close: vi.fn().mockRejectedValue(new Error('close failed')) }; + failed.waitForInitialization = vi.fn().mockRejectedValue(new Error('timeout')); + mockLdInit.mockReturnValue(failed); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient } = await import('../lifecycle.js'); + await expect(initClient()).rejects.toThrow('timeout'); + }); + + it('does not re-run telemetry setup on a repeat BYOC call', async () => { + // The repeat call's options cannot take effect: OTel's globals are + // already held. Both the early return and setupTelemetry's one-provider + // guard keep the first call's configuration. + const { initClient } = await import('../lifecycle.js'); + const byocClient = makeMockClient(); + await initClient(byocClient, { serviceName: 'first', otlpEndpoint: 'https://first.test' }); + await initClient(byocClient, { serviceName: 'second', otlpEndpoint: 'https://second.test' }); + await initClient(byocClient); + + expect(mockOTLPTraceExporter).toHaveBeenCalledOnce(); + expect(mockOTLPTraceExporter).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://first.test/v1/traces' }), + ); + expect(mockResourceFromAttributes).toHaveBeenCalledOnce(); + expect(mockResourceFromAttributes).toHaveBeenCalledWith(expect.objectContaining({ 'service.name': 'first' })); + }); + + it('returns the first client on a repeat BYOC call rather than swapping it', async () => { + const { initClient, getClient } = await import('../lifecycle.js'); + const first = makeMockClient(); + const second = makeMockClient(); + expect(await initClient(first)).toBe(first); + expect(await initClient(second)).toBe(first); + expect(getClient()).toBe(first); + }); + + it('swaps in a new BYOC client after shutdown', async () => { + const { initClient, getClient, shutdown } = await import('../lifecycle.js'); + const first = makeMockClient(); + const second = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + await initClient(first); + await shutdown(); + expect(await initClient(second)).toBe(second); + expect(getClient()).toBe(second); + }); + + it('flushes registered AI package information on a repeat BYOC call', async () => { + const client = makeMockClient(); + const { initClient } = await import('../lifecycle.js'); + await initClient(client); + client.track.mockClear(); + registerAiSdkPackage('@launchdarkly/ai-openai-agents', '0.1.1'); + await initClient(client); + + expect(client.track).toHaveBeenCalledOnce(); + expect(client.track.mock.calls[0][2].aiSdkName).toBe('@launchdarkly/ai-openai-agents'); + }); + it('flushes registered AI package information on the BYOC path', async () => { const client = makeMockClient(); registerAiSdkPackage('@launchdarkly/ai-server', '0.1.1'); @@ -351,6 +558,103 @@ describe('lifecycle', () => { expect(client.track).toHaveBeenCalledOnce(); }); + it('allows initialization after a failed init followed by shutdown', async () => { + const mockClient = makeMockClient(); + mockLdInit.mockReturnValue(mockClient); + + const { initClient, shutdown, getClient } = await import('../lifecycle.js'); + await expect(initClient()).rejects.toThrow(/LD_SDK_KEY/); + await shutdown(); + + await initClient({ sdkKey: 'test-key' }); + expect(getClient()).toBe(mockClient); + }); + + it('clears an in-flight init even though there is no client yet', async () => { + // shutdown() returns early without a client; the init promise must be + // dropped before that, or the next initClient() would await the old one. + let resolveFirst: () => void = () => {}; + const first = makeMockClient(); + first.waitForInitialization = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const second = makeMockClient(); + mockLdInit.mockReturnValueOnce(first).mockReturnValueOnce(second); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown } = await import('../lifecycle.js'); + const inFlight = initClient(); + await vi.waitFor(() => expect(first.waitForInitialization).toHaveBeenCalled()); + await shutdown(); + + await expect(initClient()).resolves.toBe(second); + expect(mockLdInit).toHaveBeenCalledTimes(2); + resolveFirst(); + await expect(inFlight).rejects.toThrow(/abandoned/); + }); + + it('does not let an abandoned init replace the client that came after it', async () => { + // shutdown() drops an in-flight attempt so the next call starts fresh, + // but the attempt keeps running. When it finishes it must not overwrite + // the newer client — leaking that one's connection — and must close its + // own instead. + let resolveFirst: () => void = () => {}; + const first = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + first.waitForInitialization = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const second = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + mockLdInit.mockReturnValueOnce(first).mockReturnValueOnce(second); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown, getClient } = await import('../lifecycle.js'); + const inFlight = initClient(); + await vi.waitFor(() => expect(first.waitForInitialization).toHaveBeenCalled()); + await shutdown(); + await initClient(); + + resolveFirst(); + await expect(inFlight).rejects.toThrow(/abandoned/); + expect(getClient()).toBe(second); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).not.toHaveBeenCalled(); + }); + + it('abandons an in-flight init when a pre-initialized client is passed meanwhile', async () => { + let resolveFirst: () => void = () => {}; + const first = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + first.waitForInitialization = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + mockLdInit.mockReturnValueOnce(first); + process.env.LD_SDK_KEY = 'test-key'; + const byocClient = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + + const { initClient, getClient } = await import('../lifecycle.js'); + const inFlight = initClient(); + await vi.waitFor(() => expect(first.waitForInitialization).toHaveBeenCalled()); + await initClient(byocClient); + + resolveFirst(); + await expect(inFlight).rejects.toThrow(/abandoned/); + expect(getClient()).toBe(byocClient); + expect(first.close).toHaveBeenCalledOnce(); + expect(byocClient.close).not.toHaveBeenCalled(); + // The BYOC call reused the in-flight attempt's provider, so it stays. + expect(tracerProviders).toHaveLength(1); + expect(mockTracerProviderShutdown).not.toHaveBeenCalled(); + expect(otel.held.trace).toBe(tracerProviders[0]); + }); + it('is a no-op (does not throw) when called without a prior initClient', async () => { const { shutdown } = await import('../lifecycle.js'); // Should not throw even though the client was never initialized. @@ -385,6 +689,306 @@ describe('lifecycle', () => { // Second call must be a no-op (not throw "client not initialized"). await expect(shutdown()).resolves.toBeUndefined(); }); + + describe('shutdown hooks', () => { + const hooksKey = Symbol.for('@launchdarkly/ai-server:shutdown-hooks'); + const hooks = () => (globalThis as any)[hooksKey] as Map void> | undefined; + + afterEach(() => { + hooks()?.delete('test: throws'); + hooks()?.delete('test: records'); + }); + + it('runs every registered hook, even before a client exists', async () => { + const { registerShutdownHook } = await import('../shutdown-hooks.js'); + const hook = vi.fn(); + registerShutdownHook('test: records', hook); + + const { shutdown } = await import('../lifecycle.js'); + await shutdown(); + + expect(hook).toHaveBeenCalledOnce(); + }); + + it('a throwing hook is logged and fails neither the other hooks nor client teardown', async () => { + // Experimental code reached from core through a hook must not break + // the core call. + const { registerShutdownHook } = await import('../shutdown-hooks.js'); + registerShutdownHook('test: throws', () => { + throw new Error('clear exploded'); + }); + const after = vi.fn(); + registerShutdownHook('test: records', after); + mockLdInit.mockReturnValue(makeMockClient()); + process.env.LD_SDK_KEY = 'test-key'; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + try { + const { initClient, shutdown } = await import('../lifecycle.js'); + await initClient(); + await expect(shutdown()).resolves.toBeUndefined(); + + expect(after).toHaveBeenCalledOnce(); + expect(mockClose).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('test: throws'), expect.any(Error)); + } finally { + warn.mockRestore(); + } + }); + + it('re-registering a name replaces the hook rather than adding one', async () => { + const { registerShutdownHook } = await import('../shutdown-hooks.js'); + const first = vi.fn(); + const second = vi.fn(); + registerShutdownHook('test: records', first); + registerShutdownHook('test: records', second); + + const { shutdown } = await import('../lifecycle.js'); + await shutdown(); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledOnce(); + }); + + it('loading the package root registers no Agent Skills hook', async () => { + // The root entry point must not import experimental code. Agent Skills + // registers its hook when its core module loads, so the hook's absence + // after loading the root shows the root never loaded that module. + await import('../index.js'); + + expect([...(hooks()?.keys() ?? [])].filter((name) => !name.startsWith('test: '))).toEqual([]); + }); + }); + }); + + describe('OTel global registration', () => { + it('registers a fresh provider after an init/shutdown/init cycle', async () => { + // Each OTel global is one-shot: unless shutdown releases it, the second + // provider's registration is refused and every span goes to the first, + // which has already been shut down. + mockLdInit.mockReturnValue(makeMockClient()); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown } = await import('../lifecycle.js'); + await initClient(); + await shutdown(); + expect(otel.trace.disable).toHaveBeenCalledOnce(); + expect(otel.context.disable).toHaveBeenCalledOnce(); + expect(otel.propagation.disable).toHaveBeenCalledOnce(); + + await initClient(); + expect(tracerProviders).toHaveLength(2); + expect(otel.held.trace).toBe(tracerProviders[1]); + expect(otel.trace.set).toHaveLastReturnedWith(true); + }); + + it('leaves globals another library registered first in place on shutdown', async () => { + const host = { name: 'host provider' }; + otel.held.trace = host; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + mockLdInit.mockReturnValue(makeMockClient()); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown } = await import('../lifecycle.js'); + await initClient(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('already registered')); + warnSpy.mockRestore(); + + await shutdown(); + // Ours is still shut down — it holds an exporter and a batch timer — but + // the global it never took stays with the host. + expect(mockTracerProviderShutdown).toHaveBeenCalledOnce(); + expect(otel.trace.disable).not.toHaveBeenCalled(); + expect(otel.held.trace).toBe(host); + // The globals that were free are ours, and are released. + expect(otel.context.disable).toHaveBeenCalledOnce(); + expect(otel.propagation.disable).toHaveBeenCalledOnce(); + }); + + it('releases only the globals it took, one API at a time', async () => { + const hostContextManager = { name: 'host context manager' }; + otel.held.context = hostContextManager; + mockLdInit.mockReturnValue(makeMockClient()); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown } = await import('../lifecycle.js'); + await initClient(); + // Our refused context manager is not left enabled alongside theirs. + expect(mockContextManagerDisable).toHaveBeenCalledOnce(); + + await shutdown(); + expect(otel.context.disable).not.toHaveBeenCalled(); + expect(otel.held.context).toBe(hostContextManager); + expect(otel.trace.disable).toHaveBeenCalledOnce(); + expect(otel.propagation.disable).toHaveBeenCalledOnce(); + }); + + it('builds one provider across repeat BYOC calls', async () => { + // A second provider would be refused registration yet replace the handle + // shutdown flushes, so the live provider would leak and never flush. + const byocClient = makeMockClient(); + const { initClient, shutdown } = await import('../lifecycle.js'); + await initClient(byocClient); + await initClient(byocClient); + expect(tracerProviders).toHaveLength(1); + + await shutdown(); + expect(mockTracerProviderShutdown.mock.contexts).toEqual([tracerProviders[0]]); + expect(otel.held.trace).toBeNull(); + }); + + it('tears down the provider of an init that times out after telemetry setup, then retries', async () => { + const failed = makeMockClient(); + failed.waitForInitialization = vi.fn().mockRejectedValue(new Error('timeout')); + mockLdInit.mockReturnValueOnce(failed).mockReturnValueOnce(makeMockClient()); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown } = await import('../lifecycle.js'); + await expect(initClient()).rejects.toThrow('timeout'); + expect(mockTracerProviderShutdown.mock.contexts).toEqual([tracerProviders[0]]); + expect(otel.held).toEqual({ trace: null, context: null, propagation: null }); + + await initClient(); + expect(tracerProviders).toHaveLength(2); + expect(otel.held.trace).toBe(tracerProviders[1]); + + // Nothing leaked: shutdown reaches the retry's provider, so each built + // provider is shut down exactly once. + await shutdown(); + expect(mockTracerProviderShutdown.mock.contexts).toEqual(tracerProviders); + }); + + it('tears down the provider of an init that fails for want of node-server-sdk, then retries', async () => { + ldSdk.installed = false; + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, getClient } = await import('../lifecycle.js'); + await expect(initClient()).rejects.toThrow(/node-server-sdk is not installed/); + expect(mockTracerProviderShutdown.mock.contexts).toEqual([tracerProviders[0]]); + expect(otel.held.trace).toBeNull(); + + ldSdk.installed = true; + const client = makeMockClient(); + mockLdInit.mockReturnValue(client); + await initClient(); + expect(getClient()).toBe(client); + expect(tracerProviders).toHaveLength(2); + expect(otel.held.trace).toBe(tracerProviders[1]); + }); + + it('tears down the provider of an init in flight at shutdown, so the next init builds its own', async () => { + // shutdown() used to return early without a client, leaving the in-flight + // attempt's provider registered for the next init to reuse — with the + // abandoned attempt's sdkKey as highlight.project_id. + let resolveFirst: () => void = () => {}; + const first = makeMockClient(); + first.waitForInitialization = vi.fn( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + mockLdInit.mockReturnValueOnce(first).mockReturnValueOnce(makeMockClient()); + + const { initClient, shutdown } = await import('../lifecycle.js'); + const inFlight = initClient({ sdkKey: 'first-key' }); + await vi.waitFor(() => expect(first.waitForInitialization).toHaveBeenCalled()); + await shutdown(); + expect(mockTracerProviderShutdown.mock.contexts).toEqual([tracerProviders[0]]); + expect(otel.held).toEqual({ trace: null, context: null, propagation: null }); + + await initClient({ sdkKey: 'second-key' }); + expect(tracerProviders).toHaveLength(2); + expect(otel.held.trace).toBe(tracerProviders[1]); + expect(mockResourceFromAttributes).toHaveBeenLastCalledWith( + expect.objectContaining({ 'highlight.project_id': 'second-key' }), + ); + + resolveFirst(); + await expect(inFlight).rejects.toThrow(/abandoned/); + // Abandoning it leaves the newer provider alone. + expect(otel.held.trace).toBe(tracerProviders[1]); + expect(mockTracerProviderShutdown).toHaveBeenCalledOnce(); + }); + + it('builds no provider for an init that shutdown abandons during telemetry setup', async () => { + // No await before shutdown(): setupTelemetry is still awaiting its + // imports, so there is no provider yet for shutdown to tear down. One + // built afterwards would outlive the attempt and be reused by the next + // init, with the abandoned attempt's sdkKey as highlight.project_id. + const first = { ...makeMockClient(), close: vi.fn().mockResolvedValue(undefined) }; + mockLdInit.mockReturnValueOnce(first).mockReturnValueOnce(makeMockClient()); + + const { initClient, shutdown } = await import('../lifecycle.js'); + const abandoned = initClient({ sdkKey: 'first-key' }); + await shutdown(); + await expect(abandoned).rejects.toThrow(/abandoned/); + expect(tracerProviders).toHaveLength(0); + expect(otel.held).toEqual({ trace: null, context: null, propagation: null }); + + await initClient({ sdkKey: 'second-key' }); + expect(tracerProviders).toHaveLength(1); + expect(otel.held.trace).toBe(tracerProviders[0]); + expect(mockResourceFromAttributes).toHaveBeenCalledOnce(); + expect(mockResourceFromAttributes).toHaveBeenCalledWith( + expect.objectContaining({ 'highlight.project_id': 'second-key' }), + ); + }); + + it('does not let an abandoned init that then fails tear down the newer provider', async () => { + // The newer attempt is still in flight, so there is no client yet to + // say the current provider is spoken for: only its identity does. + let rejectFirst: (err: Error) => void = () => {}; + const first = makeMockClient(); + first.waitForInitialization = vi.fn( + () => + new Promise((_, reject) => { + rejectFirst = reject; + }), + ); + let resolveSecond: () => void = () => {}; + const second = makeMockClient(); + second.waitForInitialization = vi.fn( + () => + new Promise((resolve) => { + resolveSecond = resolve; + }), + ); + mockLdInit.mockReturnValueOnce(first).mockReturnValueOnce(second); + process.env.LD_SDK_KEY = 'test-key'; + + const { initClient, shutdown, getClient } = await import('../lifecycle.js'); + const abandoned = initClient(); + await vi.waitFor(() => expect(first.waitForInitialization).toHaveBeenCalled()); + await shutdown(); + const current = initClient(); + await vi.waitFor(() => expect(second.waitForInitialization).toHaveBeenCalled()); + + rejectFirst(new Error('timeout')); + await expect(abandoned).rejects.toThrow('timeout'); + expect(otel.held.trace).toBe(tracerProviders[1]); + expect(mockTracerProviderShutdown.mock.contexts).toEqual([tracerProviders[0]]); + + resolveSecond(); + await expect(current).resolves.toBe(second); + expect(getClient()).toBe(second); + }); + + it('builds the retry its own provider, with the options the retry passed', async () => { + // Reusing the failed attempt's provider would stamp its spans with the + // failed attempt's sdkKey as highlight.project_id. + const failed = makeMockClient(); + failed.waitForInitialization = vi.fn().mockRejectedValue(new Error('timeout')); + mockLdInit.mockReturnValueOnce(failed).mockReturnValueOnce(makeMockClient()); + + const { initClient } = await import('../lifecycle.js'); + await expect(initClient({ sdkKey: 'first-key' })).rejects.toThrow('timeout'); + await initClient({ sdkKey: 'second-key' }); + + expect(mockResourceFromAttributes).toHaveBeenLastCalledWith( + expect.objectContaining({ 'highlight.project_id': 'second-key' }), + ); + }); }); describe('when OTel SDK packages are not installed', () => { @@ -404,7 +1008,7 @@ describe('lifecycle', () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('npm install')); warnSpy.mockRestore(); - vi.doUnmock('@opentelemetry/sdk-trace-node'); + vi.doMock('@opentelemetry/sdk-trace-node', mockNodeTracerProviderModule); }); it('getClient returns the LD client even when telemetry setup was skipped', async () => { @@ -423,7 +1027,7 @@ describe('lifecycle', () => { expect(getClient()).toBe(mockClient); vi.restoreAllMocks(); - vi.doUnmock('@opentelemetry/sdk-trace-node'); + vi.doMock('@opentelemetry/sdk-trace-node', mockNodeTracerProviderModule); }); it('shutdown does not throw for telemetry when setup was skipped', async () => { @@ -443,7 +1047,7 @@ describe('lifecycle', () => { expect(mockTracerProviderShutdown).not.toHaveBeenCalled(); vi.restoreAllMocks(); - vi.doUnmock('@opentelemetry/sdk-trace-node'); + vi.doMock('@opentelemetry/sdk-trace-node', mockNodeTracerProviderModule); }); }); @@ -468,6 +1072,30 @@ describe('lifecycle', () => { expect(result.meta?.variationKey).toBe('v1'); }); + it('does not fail on a malformed skills field', async () => { + // Agent Skills is experimental, so its field cannot break a core call + // (TESTING.md §0.3). `skillRefs` rejects it instead. + const mockClient = makeMockClient(); + mockClient.variation = vi.fn().mockResolvedValue({ + _ldMeta: { enabled: true, variationKey: 'v1', version: 1, mode: 'messages' }, + model: { name: 'gpt-4o' }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + skills: [{ key: 'My_Skill', version: 0 }], + }); + mockLdInit.mockReturnValue(mockClient); + process.env.LD_SDK_KEY = 'test-key'; + + const { inspectConfig, extractVariation } = await import('../lifecycle.js'); + const ctx = { kind: 'user' as const, key: 'user-1' }; + const result = await inspectConfig('my-flag', ctx); + const extracted = await extractVariation('my-flag', ctx); + + expect(result.enabled).toBe(true); + expect(result.config?.model.name).toBe('gpt-4o'); + expect(extracted.config.model.name).toBe('gpt-4o'); + }); + it('preserves modelKey and modelVersion from _ldMeta on meta', async () => { const mockClient = makeMockClient(); mockClient.variation = vi.fn().mockResolvedValue({ @@ -526,6 +1154,26 @@ describe('lifecycle', () => { expect(result.config).toBeNull(); }); + it('recovers once LD_SDK_KEY becomes available after a failed lazy init', async () => { + // inspectConfig swallows the init error, so a cached rejection would make + // an app that called it too early serve disabled configs forever. + const mockClient = makeMockClient(); + mockClient.variation = vi.fn().mockResolvedValue({ + _ldMeta: { enabled: true, variationKey: 'v1', version: 1, mode: 'messages' }, + model: { name: 'gpt-4o' }, + provider: { name: 'OpenAI' }, + instructions: 'You are helpful.', + }); + mockLdInit.mockReturnValue(mockClient); + + const { inspectConfig } = await import('../lifecycle.js'); + const ctx = { kind: 'user' as const, key: 'user-1' }; + expect((await inspectConfig('my-flag', ctx)).enabled).toBe(false); + + process.env.LD_SDK_KEY = 'test-key'; + expect((await inspectConfig('my-flag', ctx)).enabled).toBe(true); + }); + it('returns enabled=false when the LD client throws', async () => { const mockClient = makeMockClient(); mockClient.variation = vi.fn().mockRejectedValue(new Error('network error')); diff --git a/packages/client/src/__tests__/safe-fs.test.ts b/packages/client/src/__tests__/safe-fs.test.ts new file mode 100644 index 00000000..1dac83c2 --- /dev/null +++ b/packages/client/src/__tests__/safe-fs.test.ts @@ -0,0 +1,433 @@ +/** + * The filesystem primitives, tested directly: atomicity, exclusive temp creation, + * `0644`, and symlink refusal. + * + * Needs `vi.mock('node:fs/promises')` to observe the flags a file is *opened* + * with, which has to be hoisted above the module under test. + */ + +import { constants as fsConstants } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { openCalls, collide } = vi.hoisted(() => ({ + openCalls: [] as Array<{ target: string; flags: number }>, + collide: { remainingTempFailures: 0 }, +})); + +vi.mock('node:fs/promises', async () => { + const real = await vi.importActual('node:fs/promises'); + return { + ...real, + async open(target: string, flags: number, mode?: number) { + openCalls.push({ target: String(target), flags }); + // Simulate another process having just taken the temp name, so the O_EXCL + // retry loop is what has to recover. + if (String(target).endsWith('.tmp') && collide.remainingTempFailures > 0) { + collide.remainingTempFailures -= 1; + const error = new Error('EEXIST: file already exists') as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + return real.open(target, flags, mode); + }, + }; +}); + +const { + atomicWrite, + directoryAddress, + isDescriptorAddressed, + openDirectoryNoFollow, + openOrCreateDirectory, + SUPPORTS_PROC_FD, + unlinkNoFollow, +} = await import('../safe-fs.js'); + +let scratch: string; + +beforeEach(async () => { + openCalls.length = 0; + collide.remainingTempFailures = 0; + scratch = await realpath(await mkdtemp(path.join(tmpdir(), 'ld-ai-safe-fs-'))); +}); + +afterEach(async () => { + await rm(scratch, { recursive: true, force: true }); +}); + +function tempOpens(): Array<{ target: string; flags: number }> { + return openCalls.filter((call) => call.target.endsWith('.tmp')); +} + +describe('openOrCreateDirectory', () => { + it('creates a missing directory and pins it', async () => { + const dir = path.join(scratch, 'fresh'); + const handle = await openOrCreateDirectory(dir); + try { + expect((await handle.stat()).isDirectory()).toBe(true); + } finally { + await handle.close(); + } + }); + + it.skipIf(process.platform === 'win32')('leaves the mode of an existing directory alone', async () => { + // The explicit 0755 is for directories this call creates; one that was + // already there keeps the mode its owner chose. + const dir = path.join(scratch, 'existing'); + await mkdir(dir); + await chmod(dir, 0o700); + const handle = await openOrCreateDirectory(dir); + await handle.close(); + expect((await stat(dir)).mode & 0o777).toBe(0o700); + }); + + it('refuses an existing symlink-to-directory', async () => { + // `mkdir(..., { recursive: true })` would treat this as "already there". The + // plain mkdir plus an lstat on the EEXIST path is what refuses it. + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + const link = path.join(scratch, 'link'); + await symlink(outside, link, 'dir'); + + await expect(openOrCreateDirectory(link)).rejects.toThrow(/symlink/i); + }); + + it('refuses a path that exists as a file', async () => { + const asFile = path.join(scratch, 'a-file'); + await writeFile(asFile, 'not a directory\n', 'utf-8'); + await expect(openOrCreateDirectory(asFile)).rejects.toThrow(/not a directory/i); + }); + + it('opens with O_NOFOLLOW so a symlink cannot be followed', async () => { + const dir = path.join(scratch, 'fresh'); + const handle = await openOrCreateDirectory(dir); + await handle.close(); + + const call = openCalls.find((c) => c.target === dir); + expect(call).toBeDefined(); + expect(call?.flags & fsConstants.O_NOFOLLOW).toBe(fsConstants.O_NOFOLLOW); + }); +}); + +describe('openDirectoryNoFollow', () => { + it('refuses a symlinked directory', async () => { + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + const link = path.join(scratch, 'link'); + await symlink(outside, link, 'dir'); + + await expect(openDirectoryNoFollow(link)).rejects.toThrow(); + }); +}); + +describe('atomicWrite', () => { + it('creates the temp file exclusively, in the target directory, without following links', async () => { + // Asserting only that two successive writes pick different names would be + // satisfied by the randomness alone, so assert the flag itself. + const dir = path.join(scratch, 'skill'); + const handle = await openOrCreateDirectory(dir); + try { + await atomicWrite(dir, 'SKILL.md', Buffer.from('body\n', 'utf-8'), handle); + } finally { + await handle.close(); + } + + const temps = tempOpens(); + expect(temps).toHaveLength(1); + expect(temps[0].flags & fsConstants.O_EXCL).toBe(fsConstants.O_EXCL); + expect(temps[0].flags & fsConstants.O_CREAT).toBe(fsConstants.O_CREAT); + expect(temps[0].flags & fsConstants.O_NOFOLLOW).toBe(fsConstants.O_NOFOLLOW); + // Same directory as the target, or the rename would cross devices and stop + // being atomic. + expect(path.dirname(temps[0].target)).toBe(dir); + expect(await readFile(path.join(dir, 'SKILL.md'), 'utf-8')).toBe('body\n'); + }); + + it('retries with a fresh name when the temp path is already taken', async () => { + // Losing the race must produce a new name rather than writing through + // whatever is already there. + const dir = path.join(scratch, 'skill'); + collide.remainingTempFailures = 3; + + const handle = await openOrCreateDirectory(dir); + try { + await atomicWrite(dir, 'SKILL.md', Buffer.from('body\n', 'utf-8'), handle); + } finally { + await handle.close(); + } + + const temps = tempOpens(); + expect(temps).toHaveLength(4); + expect(new Set(temps.map((t) => t.target)).size).toBe(4); + expect(await readFile(path.join(dir, 'SKILL.md'), 'utf-8')).toBe('body\n'); + }); + + it('leaves no temp file behind on success', async () => { + const dir = path.join(scratch, 'skill'); + const handle = await openOrCreateDirectory(dir); + try { + await atomicWrite(dir, 'SKILL.md', Buffer.from('body\n', 'utf-8'), handle); + } finally { + await handle.close(); + } + expect(await readdir(dir)).toEqual(['SKILL.md']); + }); +}); + +/** + * Off `SUPPORTS_PROC_FD` the identity re-check is the *whole* defense against a + * directory swapped between validation and the destructive call: Node exposes no + * `*at()` family, so the rename and the unlink resolve their directory by path. + * The swap races in `skills-fs.test.ts` fire after the check and cannot reach it, + * so the swap is staged here: pin the handle, replace the directory, then invoke + * each primitive (both re-check independently). + * + * These pass a plain path, which exercises the floor on every platform — the + * check is skipped only for a directory addressed through `directoryAddress`. + */ +describe('pinned-directory identity re-check', () => { + /** Moves `dir` aside and leaves a symlink to `outside` in its place. */ + async function swapForSymlink(dir: string, outside: string): Promise { + const movedTo = `${dir}.real`; + await rename(dir, movedTo); + await symlink(outside, dir, 'dir'); + return movedTo; + } + + it('atomicWrite refuses when the pinned directory was swapped for a symlink', async () => { + const dir = path.join(scratch, 'skill'); + const outside = path.join(scratch, 'outside'); + await mkdir(dir); + await mkdir(outside); + + const handle = await openDirectoryNoFollow(dir); + try { + await swapForSymlink(dir, outside); + + await expect(atomicWrite(dir, 'SKILL.md', Buffer.from('body\n', 'utf-8'), handle)).rejects.toThrow(/replaced/i); + // The identity is re-checked *before* the temp file is opened as well as + // before the rename, so nothing — not even a temp file — is written + // through the swapped link. The outside directory was never touched. + expect(await readdir(outside)).toEqual([]); + expect(tempOpens()).toEqual([]); + } finally { + await handle.close(); + } + }); + + it('unlinkNoFollow refuses when the pinned directory was swapped for a symlink', async () => { + // unlink never follows a *trailing* symlink, but it does resolve the directory + // above it, so an unguarded delete through a swapped directory is a delete + // primitive with an attacker-chosen target. The victim is a real file named + // SKILL.md, so the trailing-symlink check passes and only the identity + // re-check can stop this. + const dir = path.join(scratch, 'skill'); + const outside = path.join(scratch, 'outside'); + await mkdir(dir); + await mkdir(outside); + await writeFile(path.join(dir, 'SKILL.md'), 'managed\n', 'utf-8'); + const victim = path.join(outside, 'SKILL.md'); + await writeFile(victim, 'victim content\n', 'utf-8'); + + const handle = await openDirectoryNoFollow(dir); + try { + const movedTo = await swapForSymlink(dir, outside); + + await expect(unlinkNoFollow(dir, 'SKILL.md', handle)).rejects.toThrow(/replaced/i); + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + // The managed file is untouched too: refused, not redirected. + expect(await readFile(path.join(movedTo, 'SKILL.md'), 'utf-8')).toBe('managed\n'); + } finally { + await handle.close(); + } + }); +}); + +/** + * The Linux fast path's own positive control. + * + * The block above tests the *floor*. On Linux the defense lives in how the call + * is *addressed*, and the swap races in `skills-fs.test.ts` and + * `skills-fs-root-swap.test.ts` pass when either the path check or `O_NOFOLLOW` + * works alone. So this asserts directly that `directoryAddress()` resolves from + * the pinned inode, not from the directory's name: the write lands in the right + * place despite the swap. + * + * Gated on `SUPPORTS_PROC_FD` because it tests that capability, not the floor; + * that makes it **Linux-only**, and a green macOS run is no evidence about it. + */ +describe.skipIf(!SUPPORTS_PROC_FD)('descriptor addressing on the /proc/self/fd fast path', () => { + it('atomicWrite lands in the pinned inode after the directory is swapped for a symlink', async () => { + const dir = path.join(scratch, 'skill'); + const outside = path.join(scratch, 'outside'); + await mkdir(dir); + await mkdir(outside); + + const handle = await openDirectoryNoFollow(dir); + try { + // Built *before* the swap, which is the point: the address holds the + // descriptor, so it keeps naming this inode no matter what happens to the + // name `dir`. + const address = directoryAddress(handle, dir); + expect(address).not.toBe(dir); + + const movedTo = `${dir}.real`; + await rename(dir, movedTo); + await symlink(outside, dir, 'dir'); + // The swap really is in place: `dir` now resolves outside. + expect((await lstat(dir)).isSymbolicLink()).toBe(true); + + await atomicWrite(address, 'SKILL.md', Buffer.from('body\n', 'utf-8'), handle); + + // Landed in the directory that was pinned, which is now `movedTo`. + expect(await readFile(path.join(movedTo, 'SKILL.md'), 'utf-8')).toBe('body\n'); + // And nothing at all reached the attacker's directory — not even a temp + // file, unlike the floor, where the temp is created through the link before + // the check fires. + expect(await readdir(outside)).toEqual([]); + } finally { + await handle.close(); + } + }); + + it('unlinkNoFollow removes from the pinned inode after the directory is swapped for a symlink', async () => { + // The destructive half. `unlink` never follows a trailing symlink, but it does + // resolve the directory above it — so a path-addressed unlink through a + // swapped directory removes the outside file. The victim is a real file with + // the managed name, so only the addressing can be what saves it. + const dir = path.join(scratch, 'skill'); + const outside = path.join(scratch, 'outside'); + await mkdir(dir); + await mkdir(outside); + await writeFile(path.join(dir, 'SKILL.md'), 'managed\n', 'utf-8'); + const victim = path.join(outside, 'SKILL.md'); + await writeFile(victim, 'victim content\n', 'utf-8'); + + const handle = await openDirectoryNoFollow(dir); + try { + const address = directoryAddress(handle, dir); + const movedTo = `${dir}.real`; + await rename(dir, movedTo); + await symlink(outside, dir, 'dir'); + + await unlinkNoFollow(address, 'SKILL.md', handle); + + // The managed file — the one that was pinned — is the one that went. + expect(await readdir(movedTo)).toEqual([]); + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + } finally { + await handle.close(); + } + }); +}); + +describe('isDescriptorAddressed', () => { + // Pure string logic, ungated: it decides whether the identity re-check is + // skipped, so a lookalike must never be mistaken for the descriptor address. + it('accepts exactly the address built for this handle, and nothing else on procfs', async () => { + const handle = await openDirectoryNoFollow(scratch); + try { + expect(isDescriptorAddressed(`/proc/self/fd/${handle.fd}`, handle)).toBe(true); + expect(isDescriptorAddressed(scratch, handle)).toBe(false); + expect(isDescriptorAddressed('/tmp/proc/self/fd/3', handle)).toBe(false); + for (const shape of [ + '/proc/self/fd', + '/proc/self/fd/', + `/proc/self/fd/${handle.fd}/`, + `/proc/self/fd/${handle.fd}/child`, + `/proc/self/fd/${handle.fd + 1}`, + '/proc/self/fd/../../etc', + ]) { + expect(() => isDescriptorAddressed(shape, handle), shape).toThrow(/descriptor/i); + } + } finally { + await handle.close(); + } + }); +}); + +describe('single path component names', () => { + // `name` is the one argument that becomes part of a path this module writes + // or unlinks; a caller passing `../x` would be addressing outside the pinned + // directory. The check is structural rather than trusting every caller. + const badNames = ['', '.', '..', 'a/b', '../SKILL.md', 'SKILL.md/', '/SKILL.md']; + + it.each(badNames)('atomicWrite refuses name %j', async (name) => { + const dir = path.join(scratch, 'skill'); + await mkdir(dir); + const handle = await openDirectoryNoFollow(dir); + try { + await expect(atomicWrite(dir, name, Buffer.from('body\n'), handle)).rejects.toThrow(/single path component/); + } finally { + await handle.close(); + } + expect(tempOpens()).toEqual([]); + }); + + it.each(badNames)('unlinkNoFollow refuses name %j', async (name) => { + const dir = path.join(scratch, 'skill'); + await mkdir(dir); + await writeFile(path.join(dir, 'SKILL.md'), 'body\n', 'utf-8'); + const handle = await openDirectoryNoFollow(dir); + try { + await expect(unlinkNoFollow(dir, name, handle)).rejects.toThrow(/single path component/); + } finally { + await handle.close(); + } + expect(await readdir(dir)).toEqual(['SKILL.md']); + }); +}); + +describe('unlinkNoFollow', () => { + it('removes a real file', async () => { + const dir = path.join(scratch, 'skill'); + await mkdir(dir); + await writeFile(path.join(dir, 'SKILL.md'), 'body\n', 'utf-8'); + + const handle = await openDirectoryNoFollow(dir); + try { + await unlinkNoFollow(dir, 'SKILL.md', handle); + } finally { + await handle.close(); + } + expect(await readdir(dir)).toEqual([]); + }); + + it('refuses a symlinked target, leaving the link in place', async () => { + // Unlinking a symlink never touches its victim, so "the victim survived" proves + // nothing. The observable contract is the refusal, and that the link is still + // there afterwards. + const victim = path.join(scratch, 'victim.md'); + await writeFile(victim, 'victim content\n', 'utf-8'); + const dir = path.join(scratch, 'skill'); + await mkdir(dir); + const link = path.join(dir, 'SKILL.md'); + await symlink(victim, link); + + const handle = await openDirectoryNoFollow(dir); + try { + await expect(unlinkNoFollow(dir, 'SKILL.md', handle)).rejects.toThrow(/symlink/i); + } finally { + await handle.close(); + } + + expect((await lstat(link)).isSymbolicLink()).toBe(true); + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + }); +}); diff --git a/packages/client/src/__tests__/schema.test.ts b/packages/client/src/__tests__/schema.test.ts index d35e63bb..876b90b1 100644 --- a/packages/client/src/__tests__/schema.test.ts +++ b/packages/client/src/__tests__/schema.test.ts @@ -120,6 +120,57 @@ describe('parseAiConfig', () => { }); expect(result.success).toBe(true); }); + + // ── `skills` array validation ────────────────────────────────────────────── + // + // Fail closed: a malformed reference makes the whole config malformed, because + // an SDK that silently dropped a bad reference would materialize a partial + // skill set without telling anyone. + + describe('skills', () => { + // Agent Skills is experimental, so a malformed `skills` field must not fail + // a core config call (TESTING.md §0.3). The field is passed through + // unvalidated, and `skillRefs` rejects it where the references are used + // (see skills.test.ts). + const valid = { ...base, instructions: 'You are helpful.' }; + + it('accepts a config with no skills field', () => { + expect(parseAiConfig(valid).success).toBe(true); + }); + + it('passes valid entries through unmodified', () => { + const skills = [ + { key: 'pdf-extraction', version: 2 }, + { key: 'a1', version: 1 }, + ]; + const result = parseAiConfig({ ...valid, skills }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.skills).toEqual(skills); + }); + + it.each([ + ['null', null], + ['a string', 'pdf-extraction'], + ['an object', { key: 'pdf-extraction', version: 1 }], + ['a bare string entry', ['pdf-extraction']], + ['an absent key', [{ version: 1 }]], + ['an uppercase key', [{ key: 'Evil', version: 1 }]], + ['a traversal key', [{ key: '../evil', version: 1 }]], + ['version 0', [{ key: 'a', version: 0 }]], + ['an absent version', [{ key: 'a' }]], + [ + 'one bad entry among good ones', + [ + { key: 'good', version: 1 }, + { key: 'My_Skill', version: 1 }, + ], + ], + ])('does not fail the parse for a malformed skills field: %s', (_label, skills) => { + const result = parseAiConfig({ ...valid, skills }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.skills).toEqual(skills); + }); + }); }); describe('GraphTopologySchema', () => { diff --git a/packages/client/src/__tests__/skills-fdv2.test.ts b/packages/client/src/__tests__/skills-fdv2.test.ts new file mode 100644 index 00000000..8735468f --- /dev/null +++ b/packages/client/src/__tests__/skills-fdv2.test.ts @@ -0,0 +1,5221 @@ +/** + * The FDv2 skill delivery transport. + * + * Two layers, deliberately: + * + * - **A real fake endpoint.** `FakeFDv2Endpoint` is an in-process + * `node:http` server implementing the wire contract — the `basis` query + * parameter, `Authorization`, `If-None-Match`/304, the `{"events": [...]}` + * polling envelope, and SSE for streaming. The store under test opens real + * sockets against it, so request construction and header handling are + * exercised rather than mocked. + * - **The protocol reader driven directly.** Wire semantics — which objects are + * skills, the skill's version in the wire `key` versus the payload's in + * `version`, revocation, mixed payloads — are asserted against + * `ProtocolReader`, which has no I/O, so those cases read as the contract they + * are instead of as a server script. + */ + +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { createRequire } from 'node:module'; +import { createServer as createTcpServer, type Socket, type Server as TcpServer } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + _clearState, + _setEmitterForTesting, + _setStore, + allSkills, + getSkill, + getSkillResult, + InMemorySkillStore, +} from '../skills.js'; +import { SKILL_OBJECT_KIND } from '../skills-core.js'; +import { + BACKOFF_RESET_INTERVAL_MS, + backoffDelayMs, + classifyStatus, + DEFAULT_POLL_TIMEOUT_SECONDS, + DEFAULT_STREAM_READ_TIMEOUT_SECONDS, + decodePollBody, + FatalTransportError, + FDV2_KEY_DELIMITER, + FDV2_OBJECT_KIND, + FDV2_PAYLOAD_KIND, + FDv2SkillStore, + FetchRequester, + isSkillEvent, + iterSse, + MAX_RESPONSE_CHARS, + type PollResult, + ProtocolReader, + RecoverableTransportError, + type Requester, + retryAfterMs, + SKILLS_DEFAULT_BASE_URI, + SKILLS_DEFAULT_STREAM_URI, + SkillObjectSet, + StaleRequestStateError, + seamObjectFromPut, + secondsToMs, + splitWireKey, + tombstoneFromDelete, +} from '../skills-fdv2.js'; +import { writeSkills } from '../skills-fs.js'; +import { SkillWatcher, watchSkills } from '../skills-watch.js'; +import type { RawSkillObject, ReconcileReport } from '../types.js'; + +const SDK_KEY = 'sdk-00000000-0000-4000-8000-000000000000'; +const SKILL_BODY = '---\nname: PDF Extraction\n---\nExtract text from PDFs.\n'; + +/** + * Convenience for building fixtures whose `contentHash` is correct. + * + * Deliberately the same expression the implementation hashes with, which is + * what makes it useless as an oracle: a change to the hashing rule moves every + * fixture built here along with it, and nothing in this file would fail. The + * rule is pinned independently, against the digests LaunchDarkly's delivery + * service computes, in the `contentHash contract` suite in `skills.test.ts`. + */ +const hash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex'); + +// ─── Wire builders — one place that knows the shape ────────────────────────── + +/** + * One event on the wire. `raw`, when given, is sent as the `data:` field + * verbatim instead of `data` serialized, so a test can send data that is not + * JSON. + */ +type WireEvent = { event: string; data?: unknown; raw?: string }; + +/** + * The wire `key` of one skill object: `:`. + * + * `null` builds a key with no version at all, which is how the tests spell a + * malformed object; anything else is spelled after the delimiter verbatim. + */ +function wireKey(key: string, objectVersion: unknown): string { + if (objectVersion === null) return key; + return `${key}${FDV2_KEY_DELIMITER}${String(objectVersion)}`; +} + +/** One skill `put-object` event's data, in the shape the wire delivers it. */ +function putSkill( + key = 'pdf-extraction', + { + objectVersion = 3 as unknown, + payloadVersion = 42, + content = SKILL_BODY, + contentHash = null as string | null, + omitHash = false, + } = {}, +): Record { + const envelope: Record = { + contentType: 'text/markdown', + content, + name: 'PDF Extraction', + description: 'Extracts text', + }; + if (!omitHash) envelope.contentHash = contentHash ?? hash(content); + return { + key: wireKey(key, objectVersion), + kind: FDV2_OBJECT_KIND, + version: payloadVersion, + object: envelope, + }; +} + +function deleteSkill(key = 'pdf-extraction', { objectVersion = 3 as unknown, payloadVersion = 43 } = {}) { + return { + key: wireKey(key, objectVersion), + kind: FDV2_OBJECT_KIND, + version: payloadVersion, + }; +} + +/** A flag `put-object`: the same envelope fields, a different `kind`. */ +function putFlag(key = 'my-flag', version = 17) { + return { key, kind: 'flag', version, object: { key, version, on: true, variations: [true, false] } }; +} + +function putSegment(key = 'beta-users', version = 4) { + return { key, kind: 'segment', version, object: { key, version, included: [] } }; +} + +function serverIntent(code = 'xfer-full', payloadId = 'agent-skill') { + return { payloads: [{ id: payloadId, target: 1, intentCode: code, reason: 'test' }] }; +} + +const transferred = (state = 'basis-1', version = 42) => ({ state, version }); + +const events = (...pairs: Array<[string, unknown]>): WireEvent[] => pairs.map(([event, data]) => ({ event, data })); + +function fullPayload(objectEvents: Array<[string, unknown]>, state = 'basis-1'): WireEvent[] { + return events(['server-intent', serverIntent('xfer-full')], ...objectEvents, [ + 'payload-transferred', + transferred(state), + ]); +} + +// ─── The fake endpoint ─────────────────────────────────────────────────────── + +type RecordedRequest = { + path: string; + query: Record; + authorization?: string; + ifNoneMatch?: string; + accept?: string; +}; + +/** + * An in-process server implementing the SDK-facing FDv2 contract. + * + * Scripted per request: `queuePoll` appends a response for the next `/sdk/poll`, + * `queueStream` appends a sequence of SSE events for the next `/sdk/stream`. + * Every request's path, query and headers are recorded in `requests` so the tests + * can assert on what the store actually sent — which is the only way `basis` + * round-tripping and `If-None-Match` can be checked at all. + */ +class FakeFDv2Endpoint { + readonly requests: RecordedRequest[] = []; + /** + * What a poll is answered with once the queued script runs out. 304 — + * "nothing has changed" — is the right default for a healthy environment, but + * a standing 422 is what an environment with no skill payload answers *every* + * request with, and a queue cannot express "every". + */ + defaultPollStatus = 304; + holdStreamOpen = false; + dropStreams = false; + /** When set, `/sdk/stream` answers 307 with this `Location` instead of a body. */ + redirectStreamTo: string | null = null; + /** When set alongside `holdStreamOpen`, a `heart-beat` is sent on this interval. */ + heartbeatMs: number | null = null; + private readonly heartbeats = new Set>(); + private readonly polls: Array<{ + status: number; + events: WireEvent[]; + etag?: string; + retryAfter?: string; + location?: string; + }> = []; + private readonly streams: WireEvent[][] = []; + private readonly held = new Set(); + private server!: Server; + private port = 0; + + async listen(): Promise { + this.server = createServer((req, res) => this.route(req, res)); + await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve)); + const address = this.server.address(); + this.port = typeof address === 'object' && address !== null ? address.port : 0; + } + + get baseUri(): string { + return `http://127.0.0.1:${this.port}`; + } + + queuePoll( + payloadEvents: WireEvent[] = [], + extra: { status?: number; etag?: string; retryAfter?: string; location?: string } = {}, + ): void { + this.polls.push({ + status: extra.status ?? 200, + events: payloadEvents, + etag: extra.etag, + retryAfter: extra.retryAfter, + location: extra.location, + }); + } + + queueStream(payloadEvents: WireEvent[]): void { + this.streams.push(payloadEvents); + } + + private route(req: IncomingMessage, res: ServerResponse): void { + const url = new URL(req.url ?? '/', this.baseUri); + this.requests.push({ + path: url.pathname, + query: Object.fromEntries(url.searchParams.entries()), + authorization: req.headers.authorization, + ifNoneMatch: req.headers['if-none-match'] as string | undefined, + accept: req.headers.accept, + }); + if (url.pathname === '/sdk/poll') this.servePoll(res); + else if (url.pathname === '/sdk/stream') this.serveStream(res); + else { + res.writeHead(404); + res.end(); + } + } + + private servePoll(res: ServerResponse): void { + const queued = this.polls.shift() ?? { status: this.defaultPollStatus, events: [] }; + const headers: Record = {}; + if (queued.etag) headers.ETag = queued.etag; + // Sent even when blank: a proxy that emits an empty `Retry-After` is a case + // the store has to survive, so the fake has to be able to produce one. + if (queued.retryAfter !== undefined) headers['Retry-After'] = queued.retryAfter; + if (queued.location !== undefined) headers.Location = queued.location; + if (queued.status === 200) { + const body = JSON.stringify({ events: queued.events }); + res.writeHead(200, { ...headers, 'Content-Type': 'application/json' }); + res.end(body); + return; + } + res.writeHead(queued.status, headers); + res.end(); + } + + private serveStream(res: ServerResponse): void { + if (this.redirectStreamTo !== null) { + res.writeHead(307, { Location: this.redirectStreamTo }); + res.end(); + return; + } + const payloadEvents = this.streams.shift() ?? []; + res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); + const body = payloadEvents + .map((event) => `event: ${event.event}\ndata: ${event.raw ?? JSON.stringify(event.data ?? null)}\n\n`) + .join(''); + if (this.dropStreams) { + // Kills the socket once the events have been flushed, rather than ending + // the chunked response cleanly: how a live stream actually dies, as a + // reset or a truncated chunk, not as an orderly end of body. + res.write(body, () => res.socket?.destroy()); + return; + } + res.write(body); + if (this.holdStreamOpen) { + // Held so a test can assert on the store's state without racing the + // reconnect path; released on `close`. + this.held.add(res); + if (this.heartbeatMs !== null) { + const timer = setInterval(() => res.write('event: heart-beat\ndata: {}\n\n'), this.heartbeatMs); + this.heartbeats.add(timer); + res.on('close', () => { + clearInterval(timer); + this.heartbeats.delete(timer); + }); + } + return; + } + res.end(); + } + + async close(): Promise { + for (const timer of this.heartbeats) clearInterval(timer); + this.heartbeats.clear(); + for (const res of this.held) res.end(); + this.held.clear(); + await new Promise((resolve) => this.server.close(() => resolve())); + } +} + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +let endpoint: FakeFDv2Endpoint; +/** Extra hosts a test opened, to stand for wherever a `Location` header points. */ +let extraEndpoints: FakeFDv2Endpoint[]; +let openStores: FDv2SkillStore[]; +let tempRoots: string[]; +let warnSpy: ReturnType; +let errorSpy: ReturnType; +let debugSpy: ReturnType; + +beforeEach(async () => { + endpoint = new FakeFDv2Endpoint(); + await endpoint.listen(); + extraEndpoints = []; + openStores = []; + tempRoots = []; + _clearState(); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); +}); + +afterEach(async () => { + for (const store of openStores) await store.close(); + await endpoint.close(); + for (const extra of extraEndpoints) await extra.close(); + for (const root of tempRoots) await rm(root, { recursive: true, force: true }); + _clearState(); + vi.restoreAllMocks(); +}); + +function pollStore(options: Record = {}): FDv2SkillStore { + const store = new FDv2SkillStore(SDK_KEY, { + baseUri: endpoint.baseUri, + mode: 'poll', + pollIntervalSeconds: 0.02, + initialBackoffSeconds: 0.005, + maxBackoffSeconds: 0.02, + ...options, + }); + openStores.push(store); + return store; +} + +function streamStore(options: Record = {}): FDv2SkillStore { + const store = new FDv2SkillStore(SDK_KEY, { + baseUri: endpoint.baseUri, + mode: 'stream', + initialBackoffSeconds: 0.005, + maxBackoffSeconds: 0.02, + ...options, + }); + openStores.push(store); + return store; +} + +/** A second host, to stand for wherever a `Location` header points. */ +async function secondEndpoint(): Promise { + const second = new FakeFDv2Endpoint(); + await second.listen(); + extraEndpoints.push(second); + return second; +} + +async function scratchRoot(): Promise { + const root = await mkdtemp(path.join(tmpdir(), 'ld-skills-fdv2-')); + tempRoots.push(root); + return root; +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + return predicate(); +} + +/** A telemetry emitter that records instead of emitting. */ +class RecordingEmitter { + records: Array<[string, Record]> = []; + record(signal: string, properties: Record): void { + this.records.push([signal, properties]); + } +} + +const logged = (spy: ReturnType): string => spy.mock.calls.map((call) => String(call[0])).join('\n'); + +const consoleErrors = (): string => logged(errorSpy); + +// ─── Identifying skill objects, and ignoring everything else ───────────────── + +describe('object identification', () => { + it('identifies a skill by the kind alone', () => { + expect(isSkillEvent(putSkill())).toBe(true); + }); + + it('spells the kind as the bare category name', () => { + // Object kinds on the channel are open strings and the agent-skill payload + // is `generic`, so a skill arrives under the kind its producer registered — + // `skill` — not under a broader wrapper kind. + expect(FDV2_OBJECT_KIND).toBe('skill'); + }); + + it('does not treat a flag as a skill', () => { + expect(isSkillEvent(putFlag())).toBe(false); + }); + + it('does not treat a segment as a skill', () => { + expect(isSkillEvent(putSegment())).toBe(false); + }); + + it('does not treat another generic kind as a skill', () => { + // A generic payload may carry other registered kinds one day. + expect(isSkillEvent({ ...putSkill(), kind: 'prompt-template' })).toBe(false); + }); + + it('does not treat a skill-shaped envelope under another kind as a skill', () => { + expect(isSkillEvent({ ...putSkill(), kind: 'some-future-kind' })).toBe(false); + }); + + it('consults nothing but the kind', () => { + // No secondary field narrows the kind, and none may be required. + expect(Object.keys(putSkill()).sort()).toEqual(['key', 'kind', 'object', 'version']); + }); + + it.each([null, undefined, 'skill', 3, []])('does not treat %s as a skill', (value) => { + expect(isSkillEvent(value)).toBe(false); + }); +}); + +// ─── The skill's version is in the wire key; `version` is the payload's. ───── + +describe('version translation', () => { + it('spells the wire key as key colon version', () => { + expect(putSkill('pdf-extraction', { objectVersion: 3 }).key).toBe('pdf-extraction:3'); + }); + + it('turns the version after the delimiter into the seam version', () => { + const raw = seamObjectFromPut(putSkill('pdf-extraction', { objectVersion: 3, payloadVersion: 42 })); + expect(raw?.version).toBe(3); + expect(typeof raw?.version).toBe('number'); + }); + + it('turns the key before the delimiter into the seam key', () => { + // A caller asks for `pdf-extraction`, never for `pdf-extraction:3`. + expect(seamObjectFromPut(putSkill('pdf-extraction', { objectVersion: 3 }))?.key).toBe('pdf-extraction'); + }); + + it('never lets the payload version reach the seam', () => { + // The failure this guards against is silent: a store that read `version` + // would serve verifiable content under a version number that means nothing, + // and every pinned reference would resolve to the wrong thing with no error. + const raw = seamObjectFromPut(putSkill('pdf-extraction', { objectVersion: 3, payloadVersion: 42 })); + expect(raw?.version).not.toBe(42); + expect(Object.values(raw ?? {})).not.toContain(42); + }); + + it('distinguishes the two even when the payload version is lower', () => { + expect(seamObjectFromPut(putSkill('k', { objectVersion: 99, payloadVersion: 1 }))?.version).toBe(99); + }); + + it('holds a key with no delimiter version-less', () => { + // Not defaulted from the payload version, and not dropped: verification + // reports `invalid_version` under a key the caller recognises. + const raw = seamObjectFromPut(putSkill('pdf-extraction', { objectVersion: null })); + expect(raw).not.toBeNull(); + expect(raw?.key).toBe('pdf-extraction'); + expect('version' in (raw as RawSkillObject)).toBe(false); + }); + + it.each([ + 'latest', + '', + '3.0', + '-1', + '1:2', + '3', + ])('carries a version that is not digits (%j) through as invalid', (spelling) => { + // Carried, not invented: verification reports `invalid_version` for the + // object rather than the transport reporting it absent. + const raw = seamObjectFromPut(putSkill('pdf-extraction', { objectVersion: spelling })); + expect(raw).not.toBeNull(); + expect(raw?.key).toBe('pdf-extraction'); + expect(raw?.version).toBe(spelling); + }); + + it('reads leading zeros as the same version', () => { + expect(seamObjectFromPut(putSkill('pdf-extraction', { objectVersion: '03' }))?.version).toBe(3); + }); + + it('reads the wire key the same way on a delete', () => { + const tombstone = tombstoneFromDelete(deleteSkill('pdf-extraction', { objectVersion: 3, payloadVersion: 43 })); + expect(tombstone?.key).toBe('pdf-extraction'); + expect(tombstone?.objectVersion).toBe(3); + }); + + it.each([null, 'latest', '0'])('reads a delete with no usable version (%j) as revoking every version', (spelling) => { + const tombstone = tombstoneFromDelete(deleteSkill('pdf-extraction', { objectVersion: spelling })); + expect(tombstone?.key).toBe('pdf-extraction'); + expect(tombstone?.objectVersion).toBeNull(); + }); + + it.each([ + ':3', + '', + null, + 3, + ])('drops a put whose key (%j) carries no skill key, since it has no identity', (badKey) => { + expect(seamObjectFromPut({ ...putSkill(), key: badKey })).toBeNull(); + }); + + it('drops a keyless put, which has no identity to store it under', () => { + const { key: _dropped, ...keyless } = putSkill(); + expect(seamObjectFromPut(keyless)).toBeNull(); + }); + + it('ignores a delete with no skill key', () => { + expect(tombstoneFromDelete({ ...deleteSkill(), key: ':3' })).toBeNull(); + }); + + it('splits both halves in one place', () => { + expect(splitWireKey('pdf-extraction:3')).toEqual({ key: 'pdf-extraction', hasVersion: true, version: 3 }); + expect(splitWireKey('pdf-extraction')).toEqual({ key: 'pdf-extraction', hasVersion: false }); + expect(splitWireKey('pdf-extraction:latest')).toEqual({ + key: 'pdf-extraction', + hasVersion: true, + version: 'latest', + }); + expect(splitWireKey(':3')).toBeNull(); + expect(splitWireKey('')).toBeNull(); + expect(splitWireKey(3)).toBeNull(); + }); + + it('keys the snapshot by the skill key, not the wire key', () => { + // `writeSkills('*')` builds its prune keep-set from these keys, so a + // `key:version` spelling here would make every unverifiable object fall out + // of the keep-set and take the copy already on disk with it. `allRaw` is + // what matches a held object back to the event that carried it. + const held = new SkillObjectSet(); + const wire = putSkill('pdf-extraction', { objectVersion: 3 }); + held.put(seamObjectFromPut(wire) as RawSkillObject); + expect(wire.key).toBe('pdf-extraction:3'); + expect(Object.keys(held.snapshot())).toEqual(['pdf-extraction']); + expect(held.allRaw()).toEqual([{ ...seamObjectFromPut(wire) }]); + }); + + it('copies the envelope verbatim', () => { + const raw = seamObjectFromPut(putSkill()); + expect(raw?.content).toBe(SKILL_BODY); + expect(raw?.contentHash).toBe(hash(SKILL_BODY)); + expect(raw?.name).toBe('PDF Extraction'); + expect(raw?.contentType).toBe('text/markdown'); + }); + + it('leaves an absent envelope field absent rather than defaulting it', () => { + const wire = putSkill(); + delete (wire.object as Record).name; + const raw = seamObjectFromPut(wire); + expect(raw).not.toBeNull(); + expect('name' in (raw as RawSkillObject)).toBe(false); + }); +}); + +// ─── The protocol reader ───────────────────────────────────────────────────── + +function drive(reader: ProtocolReader, payloadEvents: WireEvent[]) { + return payloadEvents.map((event) => reader.handle(event.event, event.data)); +} + +describe('protocol reader', () => { + it('commits a full transfer at payload-transferred', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + const outcomes = drive(reader, fullPayload([['put-object', putSkill()]])); + expect(held.size).toBe(1); + expect(outcomes.at(-1)?.committed).toBe(true); + expect(outcomes.at(-1)?.basis).toBe('basis-1'); + }); + + it('counts objects arriving under an unknown intent code as ignored, and warns once per intent', () => { + // A future intent code is neither a full nor a changes transfer, so its + // objects cannot be applied without guessing — and guessing could empty the + // store. They are dropped, but visibly: counted under `objectsIgnored`, with + // one warning per intent rather than one per object. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('kept')]])); + const before = warnSpy.mock.calls.length; + drive( + reader, + events( + ['server-intent', serverIntent('xfer-future')], + ['put-object', putSkill('a')], + ['put-object', putSkill('b')], + ['delete-object', deleteSkill('kept')], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(reader.diagnostics.objectsIgnored).toBe(3); + expect(reader.diagnostics.skillObjectsReceived).toBe(1); + expect(warnSpy.mock.calls.length - before).toBe(1); + expect(logged(warnSpy)).toContain('xfer-future'); + expect(held.get('kept', null)).not.toBeNull(); + expect(held.size).toBe(1); + // A fresh intent announcement warns afresh. + drive(reader, events(['server-intent', serverIntent('xfer-future')], ['put-object', putSkill('c')])); + expect(warnSpy.mock.calls.length - before).toBe(2); + }); + + it('shows nothing before payload-transferred', () => { + // A payload version is the unit of consistency; half of one is not a state. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, events(['server-intent', serverIntent('xfer-full')], ['put-object', putSkill()])); + expect(held.size).toBe(0); + }); + + it('leaves last known good intact when a full transfer is interrupted', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('pdf-extraction', { objectVersion: 1 })]])); + expect(held.get('pdf-extraction', null)).not.toBeNull(); + + drive( + reader, + events( + ['server-intent', serverIntent('xfer-full')], + ['put-object', putSkill('pdf-extraction', { objectVersion: 2 })], + ), + ); + expect(held.get('pdf-extraction', null)?.version).toBe(1); + }); + + it('replaces rather than merges on a full transfer', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('first')]])); + drive(reader, fullPayload([['put-object', putSkill('second')]], 'basis-2')); + expect(held.get('first', null)).toBeNull(); + expect(held.get('second', null)).not.toBeNull(); + }); + + it('applies deltas over what is held on a change transfer', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('first')]])); + drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['put-object', putSkill('second')], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(held.get('first', null)).not.toBeNull(); + expect(held.get('second', null)).not.toBeNull(); + }); + + it('revokes a skill on delete-object', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill()]])); + drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(held.get('pdf-extraction', null)).toBeNull(); + expect(reader.diagnostics.objectsRevoked).toBe(1); + }); + + it('reports but does not count a delete-object for a key it never held (§3.25)', () => { + // `objectsRevoked` is read precisely when somebody is working out whether a + // revocation landed, so a tombstone for nothing must not inflate it. The + // tombstone still reaches listeners through `changes`. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('kept')]])); + const outcomes = drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill('never-held')], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(reader.diagnostics.objectsRevoked).toBe(0); + expect((outcomes.at(-1)?.changes ?? []).map((raw) => ({ key: raw.key, version: raw.version }))).toEqual([ + { key: 'never-held', version: 3 }, + ]); + expect(held.size).toBe(1); + }); + + it('counts a delete-object that actually removed something — the other half', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + fullPayload([ + ['put-object', putSkill('a')], + ['put-object', putSkill('b')], + ]), + ); + drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill('a')], + ['delete-object', deleteSkill('a')], + ['payload-transferred', transferred('basis-2')], + ), + ); + // The second delete of the same object removed nothing, so one, not two. + expect(reader.diagnostics.objectsRevoked).toBe(1); + expect(held.size).toBe(1); + }); + + it('notifies a delete with a tombstone carrying no content', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill()]])); + const outcomes = drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ); + const changes = outcomes.at(-1)?.changes ?? []; + expect(changes).toEqual([{ key: 'pdf-extraction', version: 3 }]); + expect('content' in changes[0]).toBe(false); + }); + + it('leaves the other version held when one version is deleted', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + fullPayload([ + ['put-object', putSkill('pdf-extraction', { objectVersion: 2 })], + ['put-object', putSkill('pdf-extraction', { objectVersion: 3 })], + ]), + ); + drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill('pdf-extraction', { objectVersion: 3 })], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(held.get('pdf-extraction', 2)).not.toBeNull(); + expect(held.get('pdf-extraction', null)?.version).toBe(2); + }); + + it('skips flag and segment objects cleanly', () => { + // The mixed payload is the normal case, not an edge one: an environment's + // assignment carries the flagging payload alongside the agent-skill payload. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + const outcomes = drive( + reader, + fullPayload([ + ['put-object', putFlag('flag-a')], + ['put-object', putSkill('pdf-extraction')], + ['put-object', putSegment('beta-users')], + ['put-object', putFlag('flag-b')], + ['delete-object', putFlag('flag-c')], + ]), + ); + expect(held.size).toBe(1); + expect(held.get('pdf-extraction', null)).not.toBeNull(); + expect(reader.diagnostics.objectsIgnored).toBe(4); + expect(reader.diagnostics.skillObjectsReceived).toBe(1); + expect(outcomes.every((o) => !o.fatal && !o.disconnect)).toBe(true); + }); + + it('ignores an unknown kind rather than treating it as fatal', () => { + // Erroring here is the unknown-kind reconnect loop this feature must not + // reproduce — a flag-delivery outage caused by a skills rollout. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + const outcomes = drive( + reader, + fullPayload([['put-object', { key: 'x', kind: 'quantum-widget', version: 1, object: { a: 1 } }]]), + ); + expect(held.size).toBe(0); + expect(outcomes.every((o) => !o.fatal && !o.disconnect)).toBe(true); + }); + + it('ignores a delete with no usable key, and commits the rest of its transfer', () => { + // Pins TESTING.md §3.25's experimental-stage rule: warned and ignored, not + // an interruption, so the revocation it carried is lost and `revoked` stays + // served. Base ldclient interrupts instead. The choice is to be revisited + // before 1.0, and changing it should fail this test. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + fullPayload([ + ['put-object', putSkill('a', { objectVersion: 1 })], + ['put-object', putSkill('revoked', { objectVersion: 1 })], + ]), + ); + const { key: _key, ...keyless } = deleteSkill('revoked', { objectVersion: 1 }); + const outcomes = drive( + reader, + events( + ['server-intent', serverIntent('xfer-changes')], + ['put-object', putSkill('fresh', { objectVersion: 1 })], + ['delete-object', keyless], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(outcomes.every((o) => !o.fatal && !o.disconnect)).toBe(true); + expect(outcomes.at(-1)?.committed).toBe(true); + expect(held.get('revoked', null)).not.toBeNull(); + expect(held.get('fresh', null)).not.toBeNull(); + }); + + it('revokes by omission a skill whose put in an xfer-full has no usable key', () => { + // The other half of the same rule: the skill is missing from the full + // transfer, so it is revoked, and `writeSkills('*')` would prune it. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('a', { objectVersion: 1 })]])); + const { key: _key, ...keyless } = putSkill('a', { objectVersion: 1 }); + const outcomes = drive( + reader, + fullPayload( + [ + ['put-object', putSkill('b', { objectVersion: 1 })], + ['put-object', keyless], + ], + 'basis-2', + ), + ); + expect(outcomes.every((o) => !o.fatal && !o.disconnect)).toBe(true); + expect(outcomes.at(-1)?.committed).toBe(true); + expect(held.get('a', null)).toBeNull(); + expect(held.get('b', null)).not.toBeNull(); + }); + + it('ignores an unknown event name', () => { + const outcome = new ProtocolReader(new SkillObjectSet()).handle('some-future-event', { anything: true }); + expect(outcome.fatal).toBeUndefined(); + expect(outcome.disconnect).toBeUndefined(); + }); + + it('does nothing on a heartbeat', () => { + expect(new ProtocolReader(new SkillObjectSet()).handle('heart-beat', null)).toEqual({}); + }); + + it('abandons the in-flight payload on an error event', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('pdf-extraction', { objectVersion: 1 })]])); + const outcomes = drive( + reader, + events( + ['server-intent', serverIntent('xfer-full')], + ['put-object', putSkill('pdf-extraction', { objectVersion: 2 })], + ['error', { payloadId: 'agent-skill', reason: 'backend unavailable' }], + ), + ); + expect(outcomes.at(-1)?.disconnect).toBeTruthy(); + expect(held.get('pdf-extraction', null)?.version).toBe(1); + }); + + it('asks for a reconnect on goodbye', () => { + const outcome = new ProtocolReader(new SkillObjectSet()).handle('goodbye', { + reason: 'rebalancing', + silent: false, + }); + expect(outcome.disconnect).toBeTruthy(); + expect(outcome.fatal).toBeFalsy(); + // Expected, so the reconnect it asks for is not counted as a failure. + expect(outcome.expected).toBe(true); + }); + + it('treats a catastrophic goodbye as a counted disconnect, not as fatal', () => { + // Neither base JS package reads `catastrophe`, and the streamer sets it on + // its own connection-error path. Fatal stopped delivery until `start()`, so + // one server-side error ended revocation for the life of the process. + const outcome = new ProtocolReader(new SkillObjectSet()).handle('goodbye', { + reason: 'no', + silent: true, + catastrophe: true, + }); + expect(outcome.fatal).toBeFalsy(); + expect(outcome.disconnect).toMatch(/catastrophic goodbye: no/); + // Never a routine recycle, so the reconnect always counts as a failure. + expect(outcome.expected).not.toBe(true); + // Logged at error level, even when the server marks it silent. + expect(consoleErrors()).toMatch(/catastrophic goodbye: no/); + }); + + it('holds everything and commits nothing on transfer-none', () => { + // `none` is the server saying the payload held is current, so nothing is + // applied and nothing is committed. It is a *completed exchange* — which is + // what breaks a row of failures, and what lets the next request offer the + // body's etag — but it is not a payload. Publishing one would make + // `isInitialized()` true over whatever the store happens to hold, and that is + // the fact `writeSkills('*')` prunes on. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill()]])); + const [intent, outcome] = drive( + reader, + events(['server-intent', serverIntent('none')], ['payload-transferred', transferred('basis-2')]), + ); + expect(held.size).toBe(1); + expect(intent?.healthy).toBe(true); + expect(outcome?.committed).not.toBe(true); + // The intent event above already carried the up-to-date answer; the transfer + // completing it adds nothing to report. + expect(outcome?.healthy).not.toBe(true); + // Nor does it move the resume point. `basis-2` names a payload this store was + // never sent, and resuming from it would ask every later connection for + // changes since a payload it never applied. + expect(outcome?.basis).toBeNull(); + }); + + it('does not report a commit for a transfer that applied nothing', () => { + // Four shapes reach `payload-transferred` with nothing to apply: an intent + // code this SDK does not recognise, a `none` intent, a lone transfer under no + // intent at all, and a foreign payload, which built a pending set and threw + // it away. None of them applied anything, so none of them claims anything — + // neither a commit, which is what publishes the first payload + // `writeSkills('*')` prunes on, nor an up-to-date answer, which only the + // server can give and only the `none` intent does, on its own event. + // + // The transfer is still a wire fact, counted either way. + const shapes: Array<{ prior: WireEvent[]; shape: WireEvent[] }> = [ + { + prior: [], + shape: events( + ['server-intent', serverIntent('xfer-future')], + ['put-object', putSkill()], + ['payload-transferred', transferred('basis-1')], + ), + }, + { + prior: [], + shape: events(['server-intent', serverIntent('none')], ['payload-transferred', transferred('basis-1')]), + }, + { prior: [], shape: events(['payload-transferred', transferred('basis-1')]) }, + { + // A payload is foreign only once the skill payload is known, which takes + // a commit first. + prior: fullPayload([['put-object', putSkill()]], 'basis-skills'), + shape: events( + ['server-intent', serverIntent('xfer-full', 'env-flags')], + ['put-object', putSkill('other')], + ['payload-transferred', transferred('basis-flags')], + ), + }, + ]; + for (const { prior, shape } of shapes) { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, prior); + const before = held.allRaw(); + const transfers = reader.diagnostics.payloadsTransferred; + const outcome = drive(reader, shape).at(-1); + expect(outcome?.committed).not.toBe(true); + expect(outcome?.healthy).not.toBe(true); + expect(outcome?.basis).toBeNull(); + expect(held.allRaw()).toEqual(before); + expect(reader.diagnostics.payloadsTransferred).toBe(transfers + 1); + } + }); + + it('applies a put that follows a none intent on the same connection (§3.25)', () => { + // `none` says the basis is current, not that the connection is finished: + // later edits arrive as objects with no second intent. Dropping them while + // adopting the selector would move the basis past a change never applied. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill('a')]])); + const outcomes = drive( + reader, + events( + ['server-intent', serverIntent('none')], + ['put-object', putSkill('b')], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(held.get('a', null)).not.toBeNull(); + expect(held.get('b', null)?.content).toBe(SKILL_BODY); + expect(outcomes.at(-1)?.basis).toBe('basis-2'); + expect((outcomes.at(-1)?.changes ?? []).map((raw) => raw.key)).toEqual(['b']); + expect(reader.diagnostics.objectsIgnored).toBe(0); + }); + + it('applies a revocation that follows a none intent on the same connection (§3.25)', () => { + // The case that matters: a skill revoked after a routine reconnect. Dropped, + // it would keep being served, and reconnecting would not recover it, + // because the basis has already moved past the delete. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill()]])); + const outcomes = drive( + reader, + events( + ['server-intent', serverIntent('none')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ); + expect(held.get('pdf-extraction', null)).toBeNull(); + expect(reader.diagnostics.objectsRevoked).toBe(1); + expect(reader.diagnostics.objectsIgnored).toBe(0); + expect(outcomes.at(-1)?.changes).toEqual([{ key: 'pdf-extraction', version: 3 }]); + }); + + it('treats an object arriving with no intent as a delta', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, events(['put-object', putSkill()], ['payload-transferred', transferred('basis-1')])); + expect(held.size).toBe(1); + }); +}); + +// ─── Which payload a transfer completed ────────────────────────────────────── + +const payloadWarnings = (fragment: string): string[] => + logged(warnSpy) + .split('\n') + .filter((line) => line.includes(fragment)); + +/** One payload's events, with the payload it belongs to named explicitly. */ +function skillPayload( + objectEvents: Array<[string, unknown]>, + { payloadId = 'agent-skill', code = 'xfer-full', state = 'basis-1' } = {}, +): WireEvent[] { + return events(['server-intent', serverIntent(code, payloadId)], ...objectEvents, [ + 'payload-transferred', + transferred(state), + ]); +} + +describe('payload identity', () => { + // Delivery provides one payload per credential and the protocol requires a + // client to read only the first payload intent, so today the payload read is + // the payload skills arrive on. These assert the behaviour that survives if + // the first of those stops holding: another payload's `xfer-full` must not + // publish an empty skill set, because with pruning on that deletes a + // customer's materialized files. + + it('reads only the first payload intent', () => { + // Reading only the first is what the protocol asks for, however many + // arrive — the point of the rest of this suite is to make that safe. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + events( + [ + 'server-intent', + { + payloads: [ + { id: 'agent-skill', target: 1, intentCode: 'xfer-full' }, + { id: 'env-flags', target: 2, intentCode: 'none' }, + ], + }, + ], + ['put-object', putSkill()], + ['payload-transferred', transferred()], + ), + ); + expect(held.size).toBe(1); + }); + + it('warns once about more than one payload intent', () => { + const reader = new ProtocolReader(new SkillObjectSet()); + const intent = { + payloads: [ + { id: 'env-flags', target: 1, intentCode: 'xfer-changes' }, + { id: 'agent-skill', target: 2, intentCode: 'xfer-changes' }, + ], + }; + reader.handle('server-intent', intent); + reader.handle('server-intent', intent); + expect(payloadWarnings('described 2 payloads')).toHaveLength(1); + }); + + it('warns about nothing for one payload intent', () => { + drive(new ProtocolReader(new SkillObjectSet()), skillPayload([['put-object', putSkill()]])); + expect(payloadWarnings('payload')).toEqual([]); + }); + + it("does not let another payload's full transfer empty the skills held", () => { + // The case this guard exists for. A flag payload's `xfer-full` starts an + // empty pending set; applying it at `payload-transferred` would publish + // every skill as revoked, which a reconcile with pruning on reads as + // "delete these files". + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, skillPayload([['put-object', putSkill()]])); + const outcomes = drive( + reader, + skillPayload([['put-object', putFlag()]], { payloadId: 'env-flags', state: 'basis-2' }), + ); + expect(held.get('pdf-extraction', null)).not.toBeNull(); + expect(reader.diagnostics.payloadsIgnored).toBe(1); + expect(payloadWarnings('was not applied')).toHaveLength(1); + // Nothing changed, so no listener is woken to reconcile against it. + expect(outcomes.at(-1)?.changes).toEqual([]); + }); + + it('warns once about a declined transfer however often it repeats', () => { + // A polling connection sees the other payload on every poll. + const reader = new ProtocolReader(new SkillObjectSet()); + drive(reader, skillPayload([['put-object', putSkill()]])); + const foreign = skillPayload([['put-object', putFlag()]], { payloadId: 'env-flags' }); + drive(reader, foreign); + drive(reader, foreign); + expect(payloadWarnings('was not applied')).toHaveLength(1); + expect(reader.diagnostics.payloadsIgnored).toBe(2); + }); + + it('still empties the skills on a full transfer of the skill payload', () => { + // Every skill deleted is a real state, and the guard must not mask it. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, skillPayload([['put-object', putSkill()]])); + const outcomes = drive(reader, skillPayload([], { state: 'basis-2' })); + expect(held.size).toBe(0); + expect(reader.diagnostics.payloadsIgnored).toBe(0); + // And it reports the revocation, which is what wakes a listener. A full + // transfer revokes by omission — no `delete-object` says the skill is gone — + // so an empty change list here would commit an empty store silently and + // leave the revoked files on disk until the process restarted. + expect(outcomes.at(-1)?.changes).toEqual([{ key: 'pdf-extraction', version: 3 }]); + expect(reader.diagnostics.objectsRevoked).toBe(1); + }); + + it('reports the departures when a full transfer replaces the set', () => { + // The general form of the same thing: what the payload did not carry is + // gone, and a listener that reads versions needs both halves of a version + // move — the put for the arrival, the tombstone for the departure. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + skillPayload([ + ['put-object', putSkill('a')], + ['put-object', putSkill('b')], + ]), + ); + const outcomes = drive( + reader, + skillPayload([['put-object', putSkill('a', { objectVersion: 4 })]], { state: 'basis-2' }), + ); + expect(held.size).toBe(1); + // The arrival carries content, as a put's change always has; the departures + // are tombstones, spelled exactly as `delete-object`'s are. + const changes = outcomes.at(-1)?.changes ?? []; + expect(changes.map((raw) => ({ key: raw.key, version: raw.version }))).toEqual([ + { key: 'a', version: 4 }, + { key: 'a', version: 3 }, + { key: 'b', version: 3 }, + ]); + expect(changes[0].content).toBe(SKILL_BODY); + expect(changes.slice(1).every((raw) => !('content' in raw))).toBe(true); + }); + + it('does not count a version bump as a revocation', () => { + // `objectsRevoked` is the kind of counter an operator alerts on, so a + // routine version move must not raise it. The departed version is still + // reported, because a listener that reads versions needs both halves. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, skillPayload([['put-object', putSkill('pdf-extraction', { objectVersion: 1 })]])); + const outcomes = drive( + reader, + skillPayload([['put-object', putSkill('pdf-extraction', { objectVersion: 2 })]], { state: 'basis-2' }), + ); + expect(reader.diagnostics.objectsRevoked).toBe(0); + expect((outcomes.at(-1)?.changes ?? []).map((raw) => ({ key: raw.key, version: raw.version }))).toEqual([ + { key: 'pdf-extraction', version: 2 }, + { key: 'pdf-extraction', version: 1 }, + ]); + }); + + it('counts only the keys a full transfer dropped altogether', () => { + // One key moves and one leaves: exactly one revocation, both tombstones. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + skillPayload([ + ['put-object', putSkill('a', { objectVersion: 1 })], + ['put-object', putSkill('b', { objectVersion: 1 })], + ]), + ); + const outcomes = drive( + reader, + skillPayload([['put-object', putSkill('a', { objectVersion: 2 })]], { state: 'basis-2' }), + ); + expect(reader.diagnostics.objectsRevoked).toBe(1); + expect((outcomes.at(-1)?.changes ?? []).map((raw) => raw.key)).toEqual(['a', 'a', 'b']); + }); + + it('counts a key that leaves once however many versions it held', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + skillPayload([ + ['put-object', putSkill('a', { objectVersion: 1 })], + ['put-object', putSkill('a', { objectVersion: 2 })], + ]), + ); + drive(reader, skillPayload([['put-object', putSkill('b', { objectVersion: 1 })]], { state: 'basis-2' })); + expect(reader.diagnostics.objectsRevoked).toBe(1); + expect(held.get('a', null)).toBeNull(); + }); + + it('does not adopt the basis of a foreign payload announced with the none intent (§3.25)', () => { + // A `none` intent builds no pending set, and the foreign check must not be + // gated on one: the transfer that follows still names a payload, and + // adopting its selector would resume the next connection from someone + // else's payload with every diagnostic reading healthy. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill()]], 'basis-skills')); + const outcomes = drive( + reader, + events(['server-intent', serverIntent('none', 'env-flags')], ['payload-transferred', transferred('basis-flags')]), + ); + expect(outcomes.at(-1)?.basis).toBeNull(); + expect(reader.diagnostics.payloadsIgnored).toBe(1); + expect(held.get('pdf-extraction', null)).not.toBeNull(); + }); + + it('does not adopt the selector of a payload it declined', () => { + // Ignoring a foreign payload's contents while adopting its resume point + // would ask the next poll or stream to resume from someone else's payload: + // skill updates could stop arriving while every diagnostic read healthy. + const reader = new ProtocolReader(new SkillObjectSet()); + const ours = drive(reader, skillPayload([['put-object', putSkill()]], { state: 'skills-basis' })); + expect(ours.at(-1)?.basis).toBe('skills-basis'); + + const outcomes = drive( + reader, + skillPayload([['put-object', putFlag()]], { payloadId: 'env-flags', state: 'flag-basis' }), + ); + expect(outcomes.at(-1)?.basis).toBeNull(); + expect(reader.diagnostics.payloadsIgnored).toBe(1); + }); + + it('identifies the payload as the skill payload from a revocation', () => { + // A payload that only revokes is still a payload skills arrive on. + const reader = new ProtocolReader(new SkillObjectSet()); + drive(reader, skillPayload([['delete-object', deleteSkill()]], { code: 'xfer-changes' })); + drive(reader, skillPayload([['put-object', putSkill()]], { payloadId: 'env-flags' })); + expect(reader.diagnostics.payloadsIgnored).toBe(1); + }); + + it('identifies the payload from the selector when no id is named', () => { + // `payload-transferred`'s selector is the only other place a completed + // transfer names its payload. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + const unnamed = { payloads: [{ target: 1, intentCode: 'xfer-full' }] }; + drive( + reader, + events( + ['server-intent', unnamed], + ['put-object', putSkill()], + ['payload-transferred', transferred('(p:agent-skill:53)')], + ), + ); + drive( + reader, + events( + ['server-intent', unnamed], + ['put-object', putFlag()], + ['payload-transferred', transferred('(p:env-flags:12)')], + ), + ); + expect(held.get('pdf-extraction', null)).not.toBeNull(); + expect(reader.diagnostics.payloadsIgnored).toBe(1); + }); + + it('applies an unidentifiable payload rather than withholding it', () => { + // A transfer naming no payload at all is the store's own, since delivery + // sends it one payload. Withholding it would break the common case to + // defend against a hypothetical one. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, skillPayload([['put-object', putSkill()]])); + drive( + reader, + events( + ['server-intent', { payloads: [{ intentCode: 'xfer-full' }] }], + ['put-object', putSkill('pdf-extraction', { objectVersion: 4 })], + ['payload-transferred', { version: 44 }], + ), + ); + expect(held.get('pdf-extraction', null)?.version).toBe(4); + expect(reader.diagnostics.payloadsIgnored).toBe(0); + }); + + it('leaves the first transfer of a connection as the residual', () => { + // Before a skill has arrived there is nothing to compare a payload + // against, so another payload's `xfer-full` arriving first cannot be told + // apart. The multiple-payload warning is the only signal there is, which + // is why it exists. + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive( + reader, + events( + [ + 'server-intent', + { + payloads: [ + { id: 'env-flags', intentCode: 'xfer-full' }, + { id: 'agent-skill', intentCode: 'xfer-full' }, + ], + }, + ], + ['put-object', putFlag()], + ['payload-transferred', transferred()], + ), + ); + expect(held.size).toBe(0); + expect(payloadWarnings('described 2 payloads')).toHaveLength(1); + }); +}); + +// ─── The held object set ───────────────────────────────────────────────────── + +describe('the held object set', () => { + const raws: RawSkillObject[] = [ + { key: 'a', version: 1, content: 'x', contentHash: hash('x') }, + { key: 'a', version: 4, content: 'y', contentHash: hash('y') }, + { key: 'b', version: 2, content: 'z', contentHash: hash('z') }, + { key: 'malformed', version: 'not-a-version', content: 'q' }, + ]; + + const filled = (): SkillObjectSet => { + const set = new SkillObjectSet(); + for (const raw of raws) set.put({ ...raw }); + return set; + }; + + it('resolves a pin to the pinned version, not the newest', () => { + expect(filled().get('a', 1)?.content).toBe('x'); + expect(filled().get('a', null)?.content).toBe('y'); + }); + + it('falls through to the version-less entry for a pin it cannot satisfy well', () => { + // A malformed object must reach verification and be withheld with a signal, + // rather than reading as simply absent. + expect(filled().get('malformed', 7)?.content).toBe('q'); + }); + + it('misses a pin for a missing version rather than serving a version-less entry', () => { + // With valid versions held, the version-less entry is not this pin's object: + // serving it would record an integrity failure against a healthy skill. + const set = filled(); + set.put({ key: 'a', version: 'not-a-version', content: 'q' }); + expect(set.get('a', 99)).toBeNull(); + expect(set.get('a', 1)?.content).toBe('x'); + expect(set.get('a', null)?.content).toBe('y'); + }); + + it('answers null for a key it does not hold', () => { + expect(filled().get('missing', null)).toBeNull(); + expect(filled().get('a', 99)).toBeNull(); + }); + + it('collapses the snapshot to one object per key at its newest version', () => { + // `//SKILL.md` is a single path, so a whole-store consumer must + // see one object per key. + const snapshot = filled().snapshot(); + const forA = Object.values(snapshot).filter((raw) => raw.key === 'a'); + expect(forA).toHaveLength(1); + expect(forA[0].version).toBe(4); + expect(Object.keys(snapshot)).toHaveLength(3); + }); + + it('keeps a malformed object in the snapshot so verification withholds it', () => { + expect(Object.values(filled().snapshot()).some((raw) => raw.key === 'malformed')).toBe(true); + }); +}); + +// ─── The store against the fake endpoint ───────────────────────────────────── + +describe('polling against the endpoint', () => { + it('makes a polled skill retrievable through the accessors', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')?.version).toBe(3); + }); + + it('sends the SDK key and no data model version', async () => { + // No `mv`: that parameter selects the *flag* data model, and delivery + // overrides whatever a request asks for with the payload's own default for + // any non-flagging payload. Sending it would state a preference that is + // ignored, so the store states none. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(endpoint.requests[0].path).toBe('/sdk/poll'); + expect(endpoint.requests[0].authorization).toBe(SDK_KEY); + expect('mv' in endpoint.requests[0].query).toBe(false); + }); + + it('declares the agent-skill payload kind on every request', async () => { + // Delivery narrows a connection to the kinds it declares and defaults to + // flags, so a request without this is served the environment's flag payload + // and no skills at all. It is on the first request as well as the ones + // after it: the declaration selects what the connection is served rather + // than describing what it already holds, so there is no state to wait on. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(endpoint.requests[0].query.kinds).toBe('agent-skill'); + expect(endpoint.requests[0].query.basis).toBeUndefined(); + expect(endpoint.requests[1].query.kinds).toBe('agent-skill'); + expect(endpoint.requests[1].query.basis).toBe('basis-1'); + }); + + it('sends no basis on the first request', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(endpoint.requests[0].query.basis).toBeUndefined(); + }); + + it('echoes the basis from payload-transferred on the next request', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'selector-abc')); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(endpoint.requests[1].query.basis).toBe('selector-abc'); + }); + + it('advances the basis across successive payloads', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'basis-1')); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-changes')], + ['put-object', putSkill('second')], + ['payload-transferred', transferred('basis-2')], + ), + ); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 3)).toBe(true); + expect(endpoint.requests.slice(0, 3).map((r) => r.query.basis)).toEqual([undefined, 'basis-1', 'basis-2']); + }); + + it('keeps asking from the skill basis when another payload transfers', async () => { + // The wire half of the declined-payload case: the store must resume from the + // payload skills arrive on, not from the one it just threw away. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'skills-basis')); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-full', 'env-flags')], + ['put-object', putFlag()], + ['payload-transferred', transferred('flag-basis')], + ), + ); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 3)).toBe(true); + expect(endpoint.requests.slice(0, 3).map((r) => r.query.basis)).toEqual([ + undefined, + 'skills-basis', + 'skills-basis', + ]); + }); + + it('returns an ETag as If-None-Match for the basis it was issued against', async () => { + // An ETag validates one representation of one resource, and the basis is + // part of the request that names it. `W/"v1"` answers the request that + // carried no basis at all, so it is not offered once the payload it came + // with moved the basis on; `W/"v2"` answers a request from `basis-1`, which + // is still the question being asked, so it is. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'basis-1'), { etag: 'W/"v1"' }); + endpoint.queuePoll(events(['server-intent', serverIntent('none')]), { etag: 'W/"v2"' }); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(await waitUntil(() => endpoint.requests.length >= 3)).toBe(true); + expect(endpoint.requests.slice(0, 3).map((r) => r.query.basis)).toEqual([undefined, 'basis-1', 'basis-1']); + expect(endpoint.requests.slice(0, 3).map((r) => r.ifNoneMatch)).toEqual([undefined, undefined, 'W/"v2"']); + }); + + it('does not offer the etag of a body it never applied', async () => { + // The body announced a transfer and then broke off, so the payload it + // described was never committed. Offering its etag would invite a `304` + // that reports a store still missing that payload as current and healthy — + // and unlike the 200 it replaces, a 304 carries nothing to notice that on. + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-full')], + ['put-object', putSkill()], + ['error', { reason: 'cut off mid-payload' }], + ), + { etag: 'W/"v1"' }, + ); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'basis-1'), { etag: 'W/"v2"' }); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(endpoint.requests[1].ifNoneMatch).toBeUndefined(); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('keeps held content across a 304', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]]), { etag: 'W/"v1"' }); + endpoint.queuePoll([], { status: 304 }); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(await waitUntil(() => endpoint.requests.length >= 3)).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + expect(store.diagnostics.payloadsTransferred).toBe(1); + expect(store.failed).toBeNull(); + // A payload arrived, and a 304 does not take that back. + expect(store.isInitialized()).toBe(true); + }); + + it('confirms a payload on a 304 but cannot establish one', async () => { + // A 304 answers for content this store already holds, and the etag that asked + // for it is only ever adopted from a body that completed an exchange. + // Reaching one with nothing held therefore takes a server answering a request + // that carried no etag at all, and that 304 says nothing about a payload this + // store never received. + // + // Releasing `waitForSkills` on it would make `isInitialized()` true over an + // empty committed set — the fact `writeSkills('*')` prunes on — so a reconcile + // that raced delivery would delete every managed skill on disk instead of + // reporting the retrieval unavailable (§3.21). Failing closed costs a boot + // that is genuinely waiting nothing it was not already waiting for. + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 0.5 })).toBe(false); + expect(store.isInitialized()).toBe(false); + // Not a failure either: the poll was answered, and the store is still asking. + expect(store.failed).toBeNull(); + expect(endpoint.requests[0].ifNoneMatch).toBeUndefined(); + }); + + it('does not lend the etag of an intent it cannot apply to a 304', async () => { + // The chain this closes: a body under a future intent code announces and + // transfers a payload this SDK cannot apply, its etag is adopted as though the + // body had been applied in full, and the next 304 reports the empty store it + // left behind as current. That store is initialized, healthy, and authorizes a + // prune of every managed skill on disk, with nothing in the 304 to notice it + // on. + // + // An etag is adopted only from a body that completed an exchange, so the + // second request carries none and the endpoint's standing 304 cannot answer + // for content that never arrived. + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-future')], + ['put-object', putSkill()], + ['payload-transferred', transferred('basis-1')], + ), + { etag: 'W/"v1"' }, + ); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(store.isInitialized()).toBe(false); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).toBeNull(); + expect(endpoint.requests[1].ifNoneMatch).toBeUndefined(); + // Nor is the selector of a payload it could not apply a resume point. + expect(endpoint.requests.slice(0, 2).map((r) => r.query.basis)).toEqual([undefined, undefined]); + }); + + it('yields only the skill from a mixed payload', async () => { + endpoint.queuePoll( + fullPayload([ + ['put-object', putFlag('flag-a')], + ['put-object', putSegment('beta')], + ['put-object', putSkill('pdf-extraction')], + ['put-object', putFlag('flag-b')], + ]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + const held = store.allObjects(SKILL_OBJECT_KIND); + expect(Object.keys(held)).toHaveLength(1); + expect(Object.values(held)[0].key).toBe('pdf-extraction'); + expect(store.diagnostics.objectsIgnored).toBe(3); + }); + + it('removes a revoked skill', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + expect( + await waitUntil( + () => store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction') === null && store.diagnostics.objectsRevoked === 1, + ), + ).toBe(true); + }); + + it('serves only the kind it holds', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(store.getObject('flag', 'pdf-extraction')).toBeNull(); + expect(store.allObjects('flag')).toEqual({}); + }); +}); + +describe('SSE framing', () => { + const sseBody = (text: string): ReadableStream => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + + const framed = async (text: string): Promise> => { + const seen: Array<[string, unknown]> = []; + for await (const event of iterSse(sseBody(text))) seen.push(event); + return seen; + }; + + it('drops a block with no event name without eating the next event', async () => { + expect(await framed('data: {"orphan":true}\n\nevent: put-object\ndata: {}\n\n')).toEqual([['put-object', {}]]); + }); + + it('keeps both named events around a nameless block', async () => { + expect( + await framed('event: put-object\ndata: {"n":1}\n\ndata: {"orphan":true}\n\nevent: put-object\ndata: {"n":2}\n\n'), + ).toEqual([ + ['put-object', { n: 1 }], + ['put-object', { n: 2 }], + ]); + }); + + it('ignores a comment between events', async () => { + expect( + await framed('event: put-object\ndata: {"n":1}\n\n: keep-alive\n\nevent: put-object\ndata: {"n":2}\n\n'), + ).toEqual([ + ['put-object', { n: 1 }], + ['put-object', { n: 2 }], + ]); + }); + + it('dispatches a named block with no data as a null payload', async () => { + expect(await framed('event: heart-beat\n\n')).toEqual([['heart-beat', null]]); + }); + + it('fails recoverably on an event whose data is not JSON, rather than skipping it', async () => { + // Skipping it let the `payload-transferred` after it commit the transfer + // without it, and advance the basis past it. A dropped `delete-object` then + // left a revoked skill served for as long as the store ran. Failing ends the + // connection, so the transfer is abandoned and the reconnect resumes from + // the last committed basis. The base SDK's reader does the same. + const seen: Array<[string, unknown]> = []; + const failure = (async () => { + for await (const event of iterSse( + sseBody('event: put-object\ndata: {"n":1}\n\nevent: delete-object\ndata: {"key":"pdf-extr\n\n'), + )) { + seen.push(event); + } + })(); + await expect(failure).rejects.toBeInstanceOf(RecoverableTransportError); + await expect(failure).rejects.toThrow(/'delete-object' event whose data was not JSON/); + expect(seen).toEqual([['put-object', { n: 1 }]]); + }); + + it.each([ + 'server-intent', + 'put-object', + 'delete-object', + 'payload-transferred', + 'goodbye', + 'error', + ])('fails recoverably on a %s event with no data, since empty data is not JSON', async (eventName) => { + // Read as null, a delete-object would be ignored and a + // payload-transferred would commit with no selector. + for (const block of [`event: ${eventName}\n\n`, `event: ${eventName}\ndata:\n\n`]) { + const failure = framed(block); + await expect(failure).rejects.toBeInstanceOf(RecoverableTransportError); + await expect(failure).rejects.toThrow(/not JSON/); + } + }); + + it.each([ + 'server-intent', + 'put-object', + 'delete-object', + 'payload-transferred', + 'goodbye', + 'error', + ])('parses a %s event, which the reader reads the data of', async (eventName) => { + // Pins the parse set: an event dropped from it would reach `handle` with + // null data, and a catastrophic goodbye would read as a recycle. + expect(await framed(`event: ${eventName}\ndata: {"reason":"x","catastrophe":true}\n\n`)).toEqual([ + [eventName, { reason: 'x', catastrophe: true }], + ]); + }); + + it.each([ + 'heart-beat', + 'x-future-event', + ])('does not parse a %s event, so data that is not JSON leaves the connection up', async (eventName) => { + // Unknown events are ignored by contract, and the reader reads no data + // from a heart-beat, so neither may end the connection over its data. + expect(await framed(`event: ${eventName}\ndata: not json\n\nevent: put-object\ndata: {"n":1}\n\n`)).toEqual([ + [eventName, null], + ['put-object', { n: 1 }], + ]); + }); + + /** + * Feeds `iterSse` the same text in reads of a fixed size, so a test can say + * which quantity the bound is being pushed past. `sseBody` hands the whole + * body over as one read, which is the one shape a real `fetch` never produces: + * undici caps its chunks at 64 KiB (16 KiB through gzip) however much the + * server wrote at once. + */ + const chunkedBody = (text: string, chunk: number): ReadableStream => + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (let i = 0; i < text.length; i += chunk) controller.enqueue(encoder.encode(text.slice(i, i + chunk))); + controller.close(); + }, + }); + + /** Enqueues exactly the reads given, so a test can place the read boundaries. */ + const readsOf = (...reads: string[]): ReadableStream => + new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const read of reads) controller.enqueue(encoder.encode(read)); + controller.close(); + }, + }); + + const drained = async (body: ReadableStream): Promise> => { + const seen: Array<[string, unknown]> = []; + for await (const event of iterSse(body)) seen.push(event); + return seen; + }; + + it('bounds an unterminated line and fails fatally rather than buffering it without limit', async () => { + // A server (or a proxy) that never sends a newline would otherwise grow the + // line buffer until the process ran out of memory. Fatal, like a 422: the + // payload's size belongs to the environment, so a retry would download it + // again on every backoff step and be refused the same way. + const newlineless = 'x'.repeat(MAX_RESPONSE_CHARS + 1024); + const failure = drained(chunkedBody(newlineless, 1024 * 1024)); + await expect(failure).rejects.toBeInstanceOf(FatalTransportError); + await expect(failure).rejects.not.toBeInstanceOf(RecoverableTransportError); + }); + + it('bounds the accumulated data lines of one event the same way', async () => { + // Every read here ends on a line boundary, so no tail is ever carried and the + // accumulated `data:` total is the only quantity that grows. That is what + // makes this the per-event check rather than the tail check: handed the whole + // body in one read, or in reads that straddle the lines, the tail crosses + // first and this would pass without the per-event accounting it pins. + const line = `data: ${'y'.repeat(1024 * 1024)}\n`; + const dataPerLine = line.length - 'data: '.length; + const lines = Math.ceil(MAX_RESPONSE_CHARS / dataPerLine) + 1; + const body = readsOf('event: put-object\n', ...Array.from({ length: lines }, () => line)); + const failure = drained(body); + await expect(failure).rejects.toThrow(/characters of data for one event/); + await expect(failure).rejects.toBeInstanceOf(FatalTransportError); + }); + + it('accepts a read that delivered many finished events at once', async () => { + // The bound is per event, and the two quantities it measures must not be + // summed while a read is still being split: a burst whose events are each + // well inside the bound is not one oversized event, however much of it + // arrived together. Sized past the bound in total and nowhere near it per + // event, which is the only shape that tells the two apart. + const one = (i: number) => + `event: put-object\ndata: ${JSON.stringify({ i, pad: 'p'.repeat(4 * 1024 * 1024) })}\n\n`; + const count = Math.ceil(MAX_RESPONSE_CHARS / (4 * 1024 * 1024)) + 1; + const burst = Array.from({ length: count }, (_, i) => one(i)).join(''); + expect(burst.length).toBeGreaterThan(MAX_RESPONSE_CHARS); + expect(await drained(sseBody(burst))).toHaveLength(count); + }); + + it('accepts one event larger than the cap verification enforces on content', async () => { + // Content rides inline in the envelope, so the transport bound has to clear + // the 10 MiB content cap: a skill this size is verification's business to + // accept or withhold, and must reach it rather than being dropped as a + // framing failure and retried for as long as it is redelivered. + const big = JSON.stringify({ content: 'z'.repeat(11 * 1024 * 1024) }); + expect(big.length).toBeLessThan(MAX_RESPONSE_CHARS); + const framedEvents = await drained(chunkedBody(`event: put-object\ndata: ${big}\n\n`, 64 * 1024)); + expect(framedEvents).toEqual([['put-object', JSON.parse(big)]]); + }); + + it('still dispatches an event well inside the bound', async () => { + const big = JSON.stringify({ content: 'z'.repeat(64 * 1024) }); + expect(await framed(`event: put-object\ndata: ${big}\n\n`)).toEqual([['put-object', JSON.parse(big)]]); + }); +}); + +describe('streaming against the endpoint', () => { + it('lands a streamed payload', async () => { + endpoint.holdStreamOpen = true; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + const store = streamStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('advertises text/event-stream', async () => { + endpoint.holdStreamOpen = true; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + const store = streamStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(endpoint.requests[0].path).toBe('/sdk/stream'); + expect(endpoint.requests[0].accept).toBe('text/event-stream'); + }); + + it('declares the agent-skill payload kind on the stream request too', async () => { + endpoint.holdStreamOpen = true; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + const store = streamStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(endpoint.requests[0].query.kinds).toBe('agent-skill'); + }); + + it('applies a streamed revocation without a restart', async () => { + endpoint.holdStreamOpen = true; + endpoint.queueStream([ + ...fullPayload([['put-object', putSkill()]]), + ...events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ]); + const store = streamStore(); + store.start(); + expect( + await waitUntil( + () => store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction') === null && store.diagnostics.objectsRevoked === 1, + ), + ).toBe(true); + }); + + it.each([ + ['cut short', (data: string) => data.slice(0, -4)], + ['empty', () => ''], + ] as const)('abandons a transfer carrying a delete-object whose data is %s, and resumes from the last commit', async (_label, corrupt) => { + // Before, the corrupt `delete-object` was skipped and the transfer committed + // without it: `c` landed, `a` stayed, and the reconnect asked for changes + // since `basis-2`, so the revocation of `a` was never sent again. Empty + // data was read as null, with the same result. + endpoint.holdStreamOpen = true; + const deleteA = JSON.stringify(deleteSkill('a', { objectVersion: 1 })); + endpoint.queueStream([ + ...fullPayload( + [ + ['put-object', putSkill('a', { objectVersion: 1 })], + ['put-object', putSkill('b', { objectVersion: 1 })], + ], + 'basis-1', + ), + ...events(['server-intent', serverIntent('xfer-changes')], ['put-object', putSkill('c', { objectVersion: 1 })]), + { event: 'delete-object', raw: corrupt(deleteA) }, + ...events(['payload-transferred', transferred('basis-2')]), + ]); + // The clean retransmission the reconnect is answered with. + endpoint.queueStream( + events( + ['server-intent', serverIntent('xfer-changes')], + ['put-object', putSkill('c', { objectVersion: 1 })], + ['delete-object', deleteSkill('a', { objectVersion: 1 })], + ['payload-transferred', transferred('basis-2')], + ), + ); + const store = streamStore(); + const notified: string[] = []; + store.addListener(SKILL_OBJECT_KIND, (raw) => { + notified.push(`${String(raw.key)}:${String(raw.version)}${raw.content === undefined ? ' revoked' : ''}`); + }); + store.start(); + expect( + await waitUntil(() => endpoint.requests.length >= 2 && store.getObject(SKILL_OBJECT_KIND, 'a') === null), + ).toBe(true); + // The reconnect carried the basis of the last commit, not the abandoned one. + expect(endpoint.requests.map((r) => r.query.basis)).toEqual([undefined, 'basis-1']); + // Nothing from the corrupt transfer was committed: `c` reached listeners + // once, from the retransmission, not once from each. + expect(notified).toEqual(['a:1', 'b:1', 'c:1', 'a:1 revoked']); + expect(store.getObject(SKILL_OBJECT_KIND, 'b')).not.toBeNull(); + expect(store.getObject(SKILL_OBJECT_KIND, 'c')).not.toBeNull(); + expect(store.failed).toBeNull(); + expect(logged(warnSpy)).toMatch(/'delete-object' event whose data was not JSON/); + }); + + it('abandons an xfer-full carrying a put-object whose data is not JSON, rather than revoking it by omission', async () => { + // Skipped, the corrupt `put a` would leave the full transfer without `a`, + // and committing it would revoke `a` although the server still serves it. + endpoint.holdStreamOpen = true; + const putA = JSON.stringify(putSkill('a', { objectVersion: 1 })); + endpoint.queueStream([ + ...fullPayload([['put-object', putSkill('a', { objectVersion: 1 })]], 'basis-1'), + ...events(['server-intent', serverIntent('xfer-full')], ['put-object', putSkill('b', { objectVersion: 1 })]), + { event: 'put-object', raw: putA.slice(0, -4) }, + ...events(['payload-transferred', transferred('basis-2')]), + ]); + // The clean retransmission the reconnect is answered with. + endpoint.queueStream( + fullPayload( + [ + ['put-object', putSkill('b', { objectVersion: 1 })], + ['put-object', putSkill('a', { objectVersion: 1 })], + ], + 'basis-2', + ), + ); + const store = streamStore(); + const notified: string[] = []; + store.addListener(SKILL_OBJECT_KIND, (raw) => { + notified.push(`${String(raw.key)}:${String(raw.version)}${raw.content === undefined ? ' revoked' : ''}`); + }); + store.start(); + expect( + await waitUntil(() => endpoint.requests.length >= 2 && store.getObject(SKILL_OBJECT_KIND, 'b') !== null), + ).toBe(true); + // The reconnect carried the basis of the last commit, not the abandoned one. + expect(endpoint.requests.map((r) => r.query.basis)).toEqual([undefined, 'basis-1']); + // Nothing from the corrupt transfer was committed: `a` was never revoked, + // and `b` reached listeners once, from the retransmission. + expect(notified).not.toContain('a:1 revoked'); + expect(notified.filter((n) => n === 'b:1')).toHaveLength(1); + expect(store.getObject(SKILL_OBJECT_KIND, 'a')).not.toBeNull(); + expect(store.failed).toBeNull(); + expect(logged(warnSpy)).toMatch(/'put-object' event whose data was not JSON/); + }); + + it('counts a streamed catastrophic goodbye after a commit, through the SSE parser', async () => { + // The other catastrophe tests hand the reader parsed events. This one goes + // through `iterSse`, so a goodbye whose data it stopped parsing would + // arrive as null and pass as an uncounted recycle. + endpoint.holdStreamOpen = true; + endpoint.queueStream([ + ...fullPayload([['put-object', putSkill()]], 'basis-1'), + ...events(['goodbye', { reason: 'meltdown', catastrophe: true }]), + ]); + const store = streamStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(endpoint.requests[1].query.basis).toBe('basis-1'); + expect(store.diagnostics.connectionFailures).toBe(1); + expect(store.diagnostics.lastError).toBe('server sent a catastrophic goodbye: meltdown'); + expect(store.failed).toBeNull(); + expect(consoleErrors()).toMatch(/catastrophic goodbye: meltdown/); + }); + + it('reconnects with the basis it reached', async () => { + endpoint.queueStream(fullPayload([['put-object', putSkill()]], 'basis-1')); + endpoint.queueStream(events(['heart-beat', null])); + const store = streamStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(endpoint.requests[1].query.basis).toBe('basis-1'); + }); + + it('keeps content across a reconnect', async () => { + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + endpoint.queueStream(events(['heart-beat', null])); + const store = streamStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('returns promptly from close while a stream is open', async () => { + // The delivery task spends its life awaiting a read; aborting the signal is + // what interrupts it. A flag it never checks would leave a healthy stream + // running until the process exited. + endpoint.holdStreamOpen = true; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + const store = streamStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + const started = Date.now(); + await store.close(); + expect(Date.now() - started).toBeLessThan(1000); + }); + + it('does not report an interrupted stream as a failure', async () => { + endpoint.holdStreamOpen = true; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + const store = streamStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + await store.close(); + expect(store.failed).toBeNull(); + }); + + it('reconnects when the stream dies mid-body', async () => { + // A stream fails in its body far more often than at its connect. Treating + // such a failure as unexpected would stop delivery — including revocation + // — for the process lifetime the first time a socket died. + endpoint.dropStreams = true; + for (let i = 1; i <= 5; i += 1) { + endpoint.queueStream(fullPayload([['put-object', putSkill()]], `basis-${i}`)); + } + const store = streamStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => endpoint.requests.length >= 5)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.payloadsTransferred).toBeGreaterThanOrEqual(4); + }); + + it('reconnects when the stream goes quiet past readTimeoutSeconds', async () => { + // `readTimeoutSeconds` exists to bound a stream that has gone quiet so the loop + // can reconnect; tripping it must do that and not the opposite. + endpoint.holdStreamOpen = true; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + endpoint.queueStream(fullPayload([['put-object', putSkill()]], 'basis-2')); + const store = streamStore({ readTimeoutSeconds: 0.1 }); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 2)).toBe(true); + expect(store.failed).toBeNull(); + expect(await waitUntil(() => (store.diagnostics.lastError ?? '').includes('timed out'))).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); +}); + +// ─── Failure handling ──────────────────────────────────────────────────────── + +/** + * Plays a scripted sequence, so backoff is asserted without real sockets. + * + * Each outcome is an `Error` to throw, an array of `[event, data]` pairs to + * deliver (a stream that then ends, or one poll's events), or a promise to await + * as-is. Exhausted, it fails recoverably on every call. + */ +class ScriptedRequester implements Requester { + readonly calls: Array<[string | null, string | null]> = []; + + constructor(private readonly outcomes: unknown[] = []) {} + + /** + * Honours the store's abort signal the way the real requester does: a + * scripted promise that never settles still lets `close()` return, since the + * store aborts the signal and awaits the delivery loop. + */ + private async next(signal: AbortSignal): Promise> { + const outcome = this.outcomes.length > 0 ? this.outcomes.shift() : new RecoverableTransportError('x'); + if (outcome instanceof Error) throw outcome; + if (outcome instanceof Promise) { + const aborted = new Promise((_, reject) => { + const onAbort = (): void => reject(signal.reason ?? new Error('aborted')); + if (signal.aborted) onAbort(); + else signal.addEventListener('abort', onAbort, { once: true }); + }); + return Promise.race([outcome as Promise>, aborted]); + } + return outcome as Array<[string, unknown]>; + } + + async poll(basis: string | null, etag: string | null, signal: AbortSignal): Promise { + this.calls.push([basis, etag]); + return { notModified: false, events: await this.next(signal), etag: null }; + } + + async stream(basis: string | null, signal: AbortSignal): Promise> { + this.calls.push([basis, null]); + const scripted = await this.next(signal); + return (async function* () { + yield* scripted; + })(); + } +} + +const asPairs = (wire: WireEvent[]): Array<[string, unknown]> => wire.map((e) => [e.event, e.data]); + +/** + * A healthy server that recycles connections: every `stream` call succeeds, + * transfers a full payload, and then ends the connection, as LaunchDarkly and + * any proxy in between do to a long-lived stream. + */ +class RecyclingRequester implements Requester { + connections = 0; + + poll(): Promise { + throw new Error('not a polling double'); + } + + async stream(): Promise> { + this.connections += 1; + const scripted = asPairs(fullPayload([['put-object', putSkill()]], `basis-${this.connections}`)); + return (async function* () { + yield* scripted; + })(); + } +} + +/** + * A healthy server with nothing to send: every connection is answered with the + * `none` intent — what we hold is already current — and then closed with a + * goodbye, which is how a long-lived stream is recycled. Nothing commits, + * because there is nothing to commit; given a first transfer, it delivers that + * on the first connection and nothing on any connection after. + */ +class UnchangingRequester implements Requester { + connections = 0; + + constructor( + private readonly firstTransfer: WireEvent[] = [], + private readonly silent = true, + private readonly catastrophe = false, + ) {} + + poll(): Promise { + throw new Error('not a polling double'); + } + + async stream(): Promise> { + this.connections += 1; + const transfer = + this.connections === 1 && this.firstTransfer.length > 0 + ? this.firstTransfer + : events(['server-intent', serverIntent('none')]); + const scripted = asPairs([ + ...transfer, + ...events(['goodbye', { reason: 'server recycle', silent: this.silent, catastrophe: this.catastrophe }]), + ]); + return (async function* () { + yield* scripted; + })(); + } +} + +/** + * A server that says goodbye and nothing else: every connection is closed with + * a silent, non-catastrophic goodbye, without a `server-intent` ever arriving. + * Indistinguishable from a recycle at the event level, but nothing was ever + * served, so reconnecting cannot make progress. + */ +class GoodbyeOnlyRequester implements Requester { + connections = 0; + + poll(): Promise { + throw new Error('not a polling double'); + } + + async stream(): Promise> { + this.connections += 1; + const scripted = asPairs(events(['goodbye', { reason: 'recycling', silent: true }])); + return (async function* () { + yield* scripted; + })(); + } +} + +/** + * A degraded server: every connection is answered with the `none` intent, a + * completed exchange, and then dropped at once without a goodbye. + */ +class AnswerThenDropRequester implements Requester { + connections = 0; + + poll(): Promise { + throw new Error('not a polling double'); + } + + async stream(): Promise> { + this.connections += 1; + const scripted = asPairs(events(['server-intent', serverIntent('none')])); + return (async function* () { + yield* scripted; + })(); + } +} + +/** + * Plays one outcome per connection: an `Error` thrown at connect, or events + * delivered on a connection held open for `holdMs` and then dropped. Exhausted, + * it parks a connection until the store closes, freezing what was logged. + */ +class HeldStreamRequester implements Requester { + connections = 0; + + constructor(private readonly outcomes: Array) {} + + poll(): Promise { + throw new Error('not a polling double'); + } + + async stream(_basis: string | null, signal: AbortSignal): Promise> { + this.connections += 1; + const outcome = this.outcomes.shift(); + if (outcome instanceof Error) throw outcome; + const scripted = outcome === undefined ? [] : asPairs(outcome.events); + const holdMs = outcome === undefined ? Number.POSITIVE_INFINITY : outcome.holdMs; + return (async function* () { + yield* scripted; + await new Promise((resolve) => { + const timer = Number.isFinite(holdMs) ? setTimeout(resolve, holdMs) : null; + signal.addEventListener( + 'abort', + () => { + if (timer !== null) clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + })(); + } +} + +/** The delays the store logged before each counted retry, in order. */ +const retryDelays = (): number[] => + logged(warnSpy) + .split('\n') + .map((line) => /retrying in (\d+)ms/.exec(line)?.[1]) + .filter((delay): delay is string => delay !== undefined) + .map(Number); + +function scriptedStreamStore(requester: Requester, options: Record = {}): FDv2SkillStore { + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'stream', + initialBackoffSeconds: 0.001, + maxBackoffSeconds: 0.002, + requester, + ...options, + }); + openStores.push(store); + return store; +} + +describe('failure handling', () => { + it('stops on 403 and explains why', async () => { + endpoint.queuePoll([], { status: 403 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('403'); + expect(store.failed).toContain('opt-in'); + expect(consoleErrors()).toContain('opt-in'); + }); + + it('stops on 401', async () => { + endpoint.queuePoll([], { status: 401 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('401'); + }); + + it('releases waitForSkills on a fatal failure rather than hanging', async () => { + endpoint.queuePoll([], { status: 401 }); + const store = pollStore(); + store.start(); + const started = Date.now(); + // `false`, and promptly: a caller gating boot on the return value must not + // be told a payload arrived, nor be left to sit out the whole timeout. + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(false); + expect(Date.now() - started).toBeLessThan(2000); + expect(store.failed).not.toBeNull(); + }); + + it('resolves waitForSkills false immediately once delivery has given up', async () => { + endpoint.queuePoll([], { status: 401 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + const started = Date.now(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(false); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('keeps last known good servable after a fatal failure', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll([], { status: 403 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('retries a 500', async () => { + endpoint.queuePoll([], { status: 500 }); + endpoint.queuePoll([], { status: 503 }); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(store.failed).toBeNull(); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('resets the failure count on success', async () => { + endpoint.queuePoll([], { status: 500 }); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(await waitUntil(() => store.diagnostics.connectionFailures === 0)).toBe(true); + }); + + it('keeps retrying a recoverable failure well past ten in a row (§3.25)', async () => { + // There is no consecutive-failure bound. One would turn a few minutes of + // outage into a process that receives no updates, and no revocations, for + // the rest of its life. + const requester = new ScriptedRequester([asPairs(fullPayload([['put-object', putSkill()]]))]); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 0.005, + initialBackoffSeconds: 0.001, + maxBackoffSeconds: 0.002, + requester, + }); + openStores.push(store); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => store.diagnostics.connectionFailures >= 15)).toBe(true); + expect(store.failed).toBeNull(); + // Still counting, so still retrying. + const seen = store.diagnostics.connectionFailures; + expect(await waitUntil(() => store.diagnostics.connectionFailures > seen)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.lastError).not.toBeNull(); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('keeps retrying stream failures too', async () => { + const store = scriptedStreamStore(new ScriptedRequester()); + store.start(); + expect(await waitUntil(() => store.diagnostics.connectionFailures >= 15)).toBe(true); + expect(store.failed).toBeNull(); + }); + + it('runs waitForSkills to its timeout during a recoverable outage', async () => { + // Only `close()` or a fatal status ends delivery, so an outage before the + // first payload is an answer the store does not have yet. + const store = scriptedStreamStore(new ScriptedRequester()); + store.start(); + const started = Date.now(); + expect(await store.waitForSkills({ timeoutSeconds: 0.3 })).toBe(false); + expect(Date.now() - started).toBeGreaterThanOrEqual(250); + expect(store.failed).toBeNull(); + expect(store.diagnostics.connectionFailures).toBeGreaterThan(0); + }); + + it('does not count recycled stream connections as failures', async () => { + // A streaming connection only ever ends by being dropped, so a loop that + // counted every drop as a failure would back a healthy server off to + // maxBackoffSeconds and report it as failing. + const requester = new RecyclingRequester(); + const store = scriptedStreamStore(requester); + store.start(); + expect(await waitUntil(() => requester.connections >= 8)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.payloadsTransferred).toBeGreaterThanOrEqual(8); + // A drop is a failure until the next commit clears it, so the count may + // read 1 mid-reconnect. What it must never do is climb. + expect(store.diagnostics.connectionFailures).toBeLessThanOrEqual(1); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('does not count an up-to-date connection as a failure', async () => { + // An environment whose skills never change is answered with the `none` + // intent and then recycled, so nothing ever commits. Clearing the failure + // row only at a commit would back this healthy server off to maxBackoffSeconds + // and report it as failing. + const requester = new UnchangingRequester(); + const store = scriptedStreamStore(requester); + store.start(); + // Each recycle costs a 1ms backoff, so eight arrive well inside the wait. + expect(await waitUntil(() => requester.connections >= 8, 2000)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.connectionFailures).toBe(0); + expect(store.diagnostics.lastError).toBeNull(); + }); + + it('keeps delivering across many recycles', async () => { + // The same server, but with a payload transferred first: the content it + // delivered has to survive the recycles, and the count has to stay clear. + const requester = new UnchangingRequester(fullPayload([['put-object', putSkill()]], 'basis-1')); + const store = scriptedStreamStore(requester); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => requester.connections >= 6, 2000)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.connectionFailures).toBe(0); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('reconnects after a catastrophic goodbye, and counts it even after a completed exchange', async () => { + // Was fatal: delivery stopped at the first one and stayed stopped until + // `start()`. It now retries on the backoff schedule like any failure, and + // unlike a recycle it is counted even on a connection that committed. + const requester = new UnchangingRequester(fullPayload([['put-object', putSkill()]], 'basis-1'), true, true); + const store = scriptedStreamStore(requester); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => requester.connections >= 3, 2000)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.lastError).toBe('server sent a catastrophic goodbye: server recycle'); + expect(consoleErrors()).toMatch(/catastrophic goodbye: server recycle/); + expect(consoleErrors()).not.toMatch(/will not retry/); + expect(logged(warnSpy)).toMatch(/Skill delivery failed \(server sent a catastrophic goodbye: server recycle\)/); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('counts a server that only ever says goodbye as failing', async () => { + // A goodbye is exempt from the count because it is how a healthy stream is + // recycled — but a connection that says goodbye without ever sending a + // `server-intent` served nothing. Exempting that too would reconnect + // indefinitely without `diagnostics` ever saying so. + const requester = new GoodbyeOnlyRequester(); + const store = scriptedStreamStore(requester); + store.start(); + expect(await waitUntil(() => store.diagnostics.connectionFailures >= 4, 2000)).toBe(true); + expect(store.failed).toBeNull(); + expect(requester.connections).toBeGreaterThanOrEqual(4); + expect(store.diagnostics.lastError).toContain('server said goodbye: recycling'); + // Counted, so the delivery loop warns for it, as for any other failure. + expect(logged(warnSpy)).toMatch(/Skill delivery failed \(server said goodbye: recycling\); retrying in/); + }); + + it('reads no failure, no lastError and no warning across repeated answer-then-goodbye recycles (§3.25)', async () => { + // A goodbye after a completed exchange is how the server recycles a + // long-lived stream. The counter is the main outage signal now that + // `failed` is reserved for fatal failures, so a recycle must not appear in + // it at any point, nor in `lastError`, nor in any log above debug. Not + // silent, so the reader's own closing line is exercised too. + const requester = new UnchangingRequester(fullPayload([['put-object', putSkill()]], 'basis-1'), false); + const store = scriptedStreamStore(requester); + let worst = 0; + let lastError: string | null = null; + store.start(); + expect( + await waitUntil(() => { + worst = Math.max(worst, store.diagnostics.connectionFailures); + lastError = lastError ?? store.diagnostics.lastError; + return requester.connections >= 8; + }, 2000), + ).toBe(true); + expect(worst).toBe(0); + expect(lastError).toBeNull(); + expect(store.diagnostics.connectionFailures).toBe(0); + expect(store.diagnostics.lastError).toBeNull(); + expect(store.failed).toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + // Still visible at debug. + expect(logged(debugSpy)).toMatch(/FDv2 connection closing: server recycle/); + }); + + it('reports one failure, not two, for a store that fails, succeeds, then fails (§3.25)', async () => { + // Polled, so the success is a request of its own rather than a stream whose + // own end counts as the next failure. The fourth request parks, freezing + // the count after the second failure. A completed poll also starts the + // backoff over, since `pollIntervalSeconds` already spaces the requests. + // No jitter, so the first step is exactly `initialBackoffSeconds` and the second + // exactly twice it. + vi.spyOn(Math, 'random').mockReturnValue(0); + const requester = new ScriptedRequester([ + new RecoverableTransportError('x'), + asPairs(fullPayload([['put-object', putSkill()]])), + new RecoverableTransportError('y'), + new Promise(() => {}), + ]); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 0.005, + initialBackoffSeconds: 0.1, + maxBackoffSeconds: 10, + requester, + }); + openStores.push(store); + store.start(); + expect(await waitUntil(() => requester.calls.length === 4)).toBe(true); + expect(store.diagnostics.connectionFailures).toBe(1); + expect(store.diagnostics.lastError).toBe('y'); + // And the retry was on the first step of the backoff (at most + // initialBackoffSeconds), not the second (more than it). + const retries = logged(warnSpy) + .split('\n') + .map((line) => /Skill delivery failed \(y\); retrying in (\d+)ms/.exec(line)?.[1]) + .filter((delay): delay is string => delay !== undefined); + expect(retries).toHaveLength(1); + expect(Number(retries[0])).toBe(100); + }); + + it('reconnects a server that answers none and drops at once on a growing delay (§3.25)', async () => { + // Each connection completes an exchange, so the failure count resets every + // time and never climbs past one. The delay must not reset with it: a + // degraded server that answers and then drops would otherwise be + // reconnected at `initialBackoffSeconds`, by every process, for as long as it + // stayed degraded. Only a stream that stays open resets the delay. + vi.spyOn(Math, 'random').mockReturnValue(0); + const requester = new AnswerThenDropRequester(); + const store = scriptedStreamStore(requester, { initialBackoffSeconds: 0.002, maxBackoffSeconds: 10 }); + let worst = 0; + store.start(); + expect( + await waitUntil(() => { + worst = Math.max(worst, store.diagnostics.connectionFailures); + return requester.connections >= 6; + }), + ).toBe(true); + expect(worst).toBeLessThanOrEqual(1); + expect(store.diagnostics.connectionFailures).toBeLessThanOrEqual(1); + expect(store.failed).toBeNull(); + // Doubling from the first step on every reconnect: 2, 4, 8, 16, 32. + expect(retryDelays().slice(0, 5)).toEqual([2, 4, 8, 16, 32]); + }); + + it('retries at the first step after a stream that stayed open past the reset interval (§3.25)', async () => { + // Three refused connects advance the step to 8ms; the fourth connection + // answers and is held open past the (shortened) reset interval before it + // drops, so the reconnect after it starts over at `initialBackoffSeconds`. + vi.spyOn(Math, 'random').mockReturnValue(0); + const requester = new HeldStreamRequester([ + new RecoverableTransportError('x'), + new RecoverableTransportError('x'), + new RecoverableTransportError('x'), + { events: events(['server-intent', serverIntent('none')]), holdMs: 80 }, + ]); + const store = scriptedStreamStore(requester, { initialBackoffSeconds: 0.002, maxBackoffSeconds: 10 }); + store._backoffResetIntervalMs = 20; + store.start(); + expect(await waitUntil(() => requester.connections === 5)).toBe(true); + expect(retryDelays()).toEqual([2, 4, 8, 2]); + }); + + it('keeps advancing the step after a stream that dropped inside the reset interval (§3.25)', async () => { + // The control for the test above: the same script against the real + // interval, so the reset there is the uptime's doing and nothing else's. + vi.spyOn(Math, 'random').mockReturnValue(0); + const requester = new HeldStreamRequester([ + new RecoverableTransportError('x'), + new RecoverableTransportError('x'), + new RecoverableTransportError('x'), + { events: events(['server-intent', serverIntent('none')]), holdMs: 80 }, + ]); + const store = scriptedStreamStore(requester, { initialBackoffSeconds: 0.002, maxBackoffSeconds: 10 }); + expect(store._backoffResetIntervalMs).toBe(BACKOFF_RESET_INTERVAL_MS); + expect(BACKOFF_RESET_INTERVAL_MS).toBe(60_000); + store.start(); + expect(await waitUntil(() => requester.connections === 5)).toBe(true); + expect(retryDelays()).toEqual([2, 4, 8, 16]); + }); + + it('resets the failure count on a stream commit', async () => { + // Three failures, then a commit: the stream's own drop after the commit is + // failure one again, not four. + const requester = new ScriptedRequester([ + new RecoverableTransportError('x'), + new RecoverableTransportError('x'), + new RecoverableTransportError('x'), + asPairs(fullPayload([['put-object', putSkill()]])), + new Promise(() => {}), + ]); + const store = scriptedStreamStore(requester); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => requester.calls.length === 5)).toBe(true); + expect(store.diagnostics.connectionFailures).toBe(1); + expect(store.diagnostics.lastError).toContain('closed unexpectedly'); + }); + + it('counts each drop of a server that announces a transfer and drops before committing (§3.25)', async () => { + // An `xfer-full` intent is a promise, not a delivery. A server that sends + // one and drops before `payload-transferred` has delivered nothing, and a + // store that counted the announcement as health would report it healthy + // for as long as it kept doing so. Only a committed payload or a `none` + // intent resets the row of failures. + const outcomes: unknown[] = []; + for (let i = 0; i < 5; i += 1) { + outcomes.push(asPairs(events(['server-intent', serverIntent('xfer-full')], ['put-object', putSkill()]))); + } + outcomes.push(new Promise(() => {})); + const requester = new ScriptedRequester(outcomes); + const store = scriptedStreamStore(requester); + store.start(); + expect(await waitUntil(() => requester.calls.length === 6, 5000)).toBe(true); + expect(store.diagnostics.connectionFailures).toBe(5); + expect(store.failed).toBeNull(); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).toBeNull(); + }); + + it('revokes and notifies on a delete that follows a none intent on a live stream (§3.25)', async () => { + // The reconnect is answered `none`, and the revocation comes later on the + // same connection with no second intent. The listener is what wakes a + // watcher to prune, so it must see the tombstone. + const requester = new ScriptedRequester([ + asPairs(fullPayload([['put-object', putSkill()]], 'basis-1')), + asPairs( + events( + ['server-intent', serverIntent('none')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ), + new Promise(() => {}), + ]); + const store = scriptedStreamStore(requester); + const seen: RawSkillObject[] = []; + store.addListener(SKILL_OBJECT_KIND, (raw) => { + seen.push(raw); + }); + store.start(); + expect(await waitUntil(() => store.diagnostics.payloadsTransferred === 2)).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).toBeNull(); + expect(store.diagnostics.objectsRevoked).toBe(1); + expect(store.diagnostics.objectsIgnored).toBe(0); + expect(seen.at(-1)).toEqual({ key: 'pdf-extraction', version: 3 }); + // The next connection resumes from the basis the revocation committed. + expect(await waitUntil(() => requester.calls.length === 3)).toBe(true); + expect(requester.calls[2][0]).toBe('basis-2'); + }); + + it('honours a Retry-After header off the wire', async () => { + endpoint.queuePoll([], { status: 429, retryAfter: '1' }); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + // A one-second request sitting between a 20ms backoff and a 5s cap, so the + // wait that follows can only have come from the header. + const store = pollStore({ initialBackoffSeconds: 0.02, maxBackoffSeconds: 5 }); + const started = Date.now(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 4 })).toBe(true); + expect(Date.now() - started).toBeGreaterThanOrEqual(900); + }); + + it('does not treat a blank Retry-After as no delay at all', async () => { + // `Number('')` is 0 and finite, so a header a proxy sent empty would win + // over the backoff and reconnect with no wait, a tight loop against the + // endpoint for as long as the proxy kept doing it. + for (let i = 0; i < 5; i += 1) endpoint.queuePoll([], { status: 503, retryAfter: '' }); + const store = pollStore({ initialBackoffSeconds: 0.1, maxBackoffSeconds: 0.1 }); + const started = Date.now(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length >= 4, 4000)).toBe(true); + // Three backoffs before the fourth request. Jitter can halve each, so 120ms + // is the floor; collapsed, the whole run lands in single digits. + expect(Date.now() - started).toBeGreaterThanOrEqual(120); + }); + + it('floors an honoured Retry-After at initialBackoffSeconds', async () => { + // `Retry-After: 0` is legal and means "try again now". Taken literally it + // is a busy loop against the endpoint, so it is honoured as the shortest + // delay the store was configured to wait. + const requester = new ScriptedRequester([ + new RecoverableTransportError('slow down', 0), + asPairs(fullPayload([['put-object', putSkill()]])), + ]); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 10, + initialBackoffSeconds: 0.2, + maxBackoffSeconds: 5, + requester, + }); + openStores.push(store); + const started = Date.now(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 3 })).toBe(true); + expect(Date.now() - started).toBeGreaterThanOrEqual(180); + }); + + it('recovers from a 400 by dropping the basis and asking again', async () => { + // The request carries the SDK key, a `basis` selector and an etag. A + // selector the server has stopped accepting is rejected as a 400, and + // treating that as permanently fatal would stop delivery for the process + // lifetime over state the store could simply drop. + endpoint.queuePoll(fullPayload([['put-object', putSkill('first')]], 'basis-1'), { etag: 'etag-1' }); + endpoint.queuePoll([], { status: 400 }); + endpoint.queuePoll(fullPayload([['put-object', putSkill('second')]], 'basis-2')); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.getObject(SKILL_OBJECT_KIND, 'second') !== null)).toBe(true); + expect(store.failed).toBeNull(); + // The retry asks from scratch: no selector, and no etag that would let the + // server answer 304 for a basis it just rejected. + const retry = endpoint.requests[2]; + expect(retry.query.basis).toBeUndefined(); + expect(retry.ifNoneMatch).toBeUndefined(); + }); + + it('asks from scratch after a 400 that follows a recoverable failure', async () => { + // The repair is owed however the previous request failed. + endpoint.queuePoll(fullPayload([['put-object', putSkill('first')]], 'basis-1'), { etag: 'etag-1' }); + endpoint.queuePoll([], { status: 500 }); + endpoint.queuePoll([], { status: 400 }); + endpoint.queuePoll(fullPayload([['put-object', putSkill('second')]], 'basis-2')); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.getObject(SKILL_OBJECT_KIND, 'second') !== null)).toBe(true); + expect(store.failed).toBeNull(); + const repair = endpoint.requests[3]; + expect(repair.query.basis).toBeUndefined(); + expect(repair.ifNoneMatch).toBeUndefined(); + }); + + it('still stops on the second 400 when a recoverable failure sits between them', async () => { + // The 400 is retried once because the retry drops the state; a 500 in + // between restores none, so the next 400 is answering a stateless request. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'basis-1')); + endpoint.queuePoll([], { status: 400 }); + endpoint.queuePoll([], { status: 500 }); + endpoint.queuePoll([], { status: 400 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('400'); + expect(endpoint.requests).toHaveLength(4); + }); + + it('stops on a 400 for a request that carried no basis', async () => { + // Nothing left to drop: the request was already the from-scratch one, so + // the endpoint is refusing the request itself. + endpoint.queuePoll([], { status: 400 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('400'); + expect(store.failed).toContain('base URI'); + expect(store.failed).toContain('FDv2'); + expect(endpoint.requests).toHaveLength(1); + }); + + it('stops on a second 400 after the basis has been dropped', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'basis-1')); + endpoint.queuePoll([], { status: 400 }); + endpoint.queuePoll([], { status: 400 }); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('400'); + expect(endpoint.requests).toHaveLength(3); + // The content the first transfer delivered is still servable. + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('neither dies on nor parks behind an unreasonable Retry-After', async () => { + // `Retry-After` is a request and `maxBackoffSeconds` is a promise: the header + // may come from a proxy rather than LaunchDarkly, and an hour would park + // revocation for that long. + const requester = new ScriptedRequester([ + new RecoverableTransportError('slow down', 3_600_000), + asPairs(fullPayload([['put-object', putSkill()]])), + ]); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 10, + initialBackoffSeconds: 0.02, + maxBackoffSeconds: 0.02, + requester, + }); + openStores.push(store); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 3 })).toBe(true); + expect(requester.calls.length).toBeGreaterThanOrEqual(2); + expect(store.failed).toBeNull(); + }); + + it('parses Retry-After into milliseconds', () => { + expect(retryAfterMs(new Headers({ 'Retry-After': '2' }))).toBe(2000); + expect(retryAfterMs(new Headers({ 'Retry-After': 'Wed, 21 Oct 2026 07:28:00 GMT' }))).toBeNull(); + expect(retryAfterMs(new Headers())).toBeNull(); + // A blank value is absent, not zero. + expect(retryAfterMs(new Headers({ 'Retry-After': '' }))).toBeNull(); + expect(retryAfterMs(new Headers({ 'Retry-After': ' ' }))).toBeNull(); + expect(retryAfterMs(new Headers({ 'Retry-After': '0' }))).toBe(0); + }); + + it('classifies statuses into recoverable and fatal', () => { + expect(classifyStatus(500).constructor.name).toBe('RecoverableTransportError'); + expect(classifyStatus(429).constructor.name).toBe('RecoverableTransportError'); + expect(classifyStatus(401).constructor.name).toBe('FatalTransportError'); + expect(classifyStatus(403).constructor.name).toBe('FatalTransportError'); + expect(classifyStatus(404).constructor.name).toBe('FatalTransportError'); + expect(classifyStatus(405).constructor.name).toBe('FatalTransportError'); + // A 400 may be the selector the request carried rather than the request + // itself, so it is retried once from scratch before it is fatal. + expect(classifyStatus(400)).toBeInstanceOf(StaleRequestStateError); + expect(classifyStatus(400)).toBeInstanceOf(RecoverableTransportError); + expect(classifyStatus(400).message).toContain('base URI'); + // 422 is fatal, and LaunchDarkly chose the status to be terminal rather + // than the SDK inferring it: a connection whose declared kinds exclude + // every payload it is assigned will never be assigned one. + expect(classifyStatus(422)).toBeInstanceOf(FatalTransportError); + expect(classifyStatus(422)).not.toBeInstanceOf(RecoverableTransportError); + }); + + it('answers every status with exactly one of the two classes', () => { + // There is no third class. A status handled as neither recoverable nor + // fatal is a retry loop off the backoff schedule, invisible to both + // `failed` and `connectionFailures`. + const statuses = [ + 301, 302, 307, 308, 400, 401, 402, 403, 404, 405, 406, 408, 409, 410, 413, 414, 418, 422, 425, 429, 431, 451, 500, + 501, 502, 503, 504, 507, 599, + ]; + for (const status of statuses) { + const err = classifyStatus(status); + const recoverable = err instanceof RecoverableTransportError; + const fatal = err instanceof FatalTransportError; + // Exactly one, for every status: not neither, and not both. + expect([recoverable, fatal].filter(Boolean), `HTTP ${status}`).toHaveLength(1); + } + }); + + it('names the key scoping in the 422 message', () => { + const message = classifyStatus(422).message; + expect(message).toMatch(/422/); + expect(message).toMatch(/view-scoped/i); + expect(message).toMatch(/Check your SDK key or contact LaunchDarkly support/i); + }); + + it('stops delivery on a 422 answering the first request', async () => { + // LaunchDarkly picked a non-400 4xx precisely so an SDK asking for a + // payload it will never be assigned stops instead of hammering the fleet. + // So the first response is enough: no retry, and nothing committed. + endpoint.defaultPollStatus = 422; + // Nothing else stops a loop, so what stops this one is the classification. + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toMatch(/422/); + expect(store.isInitialized()).toBe(false); + // One request, and it was not retried. Asserted after a window that would + // comfortably hold several retries at this store's 5ms initial backoff. + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(endpoint.requests).toHaveLength(1); + expect(consoleErrors()).toMatch(/will not retry/); + }); + + it('accounts a fatal 422 on failed and lastError, and not on connectionFailures', async () => { + // `connectionFailures` measures consecutive *recoverable* failures, the + // ones driving the backoff. A fatal never retries, so moving it would make + // a store that gave up on its first response look like one riding out an + // outage. This is how the give-up path already accounts 401 and 404; 422 is + // not a special case. + endpoint.defaultPollStatus = 422; + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.diagnostics.lastError).not.toBeNull(); + expect(store.diagnostics.lastError).toMatch(/422/); + expect(store.diagnostics.connectionFailures).toBe(0); + // And no per-attempt retry warning, because there was no retry. + expect(logged(warnSpy)).not.toMatch(/Skill delivery failed/); + }); + + it('resolves waitForSkills false immediately on a fatal 422, not at the timeout', async () => { + // The observable difference from treating the status as recoverable, and as + // much the point of the classification as the stopped retries are: a boot + // gated on skills behind a long wait would otherwise pay that wait on every + // start against a store that knew the answer on its first response. + endpoint.defaultPollStatus = 422; + const store = pollStore(); + store.start(); + const timeoutSeconds = 10; + const began = performance.now(); + // Waiting this out would take ten seconds; returning early takes the one + // round trip to the loopback endpoint. + expect(await store.waitForSkills({ timeoutSeconds })).toBe(false); + const elapsed = performance.now() - began; + expect(elapsed).toBeLessThan((timeoutSeconds * 1000) / 4); + expect(store.failed).not.toBeNull(); + }); + + it('leaves a store that gave up on a 422 uninitialized, so a wildcard reconcile prunes nothing', async () => { + // "Delivery gave up, therefore the store is empty, therefore prune" is an + // easy inference to assemble, and it deletes a customer's files. The readiness gate + // is what stops it, and this is the path a filesystem-agent deployment + // takes when Agent Skills is not enabled for the account. + const root = await scratchRoot(); + const stale = path.join(root, 'left-behind'); + await mkdir(stale, { recursive: true }); + await writeFile(path.join(stale, 'SKILL.md'), 'not ours to delete', 'utf8'); + + endpoint.defaultPollStatus = 422; + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + // A store that gave up before any payload committed never initialized, and + // neither wait parks: both answers have already arrived. + expect(store.isInitialized()).toBe(false); + expect(await store.waitForSkills({ timeoutSeconds: 0.1 })).toBe(false); + _setStore(store); + const report = await writeSkills('*', root); + // Retrieval is reported unavailable rather than answered as "no skills". + expect(report.ok).toBe(false); + expect(report.errors.map((action) => action.error).join('\n')).toMatch(/skill retrieval unavailable/); + // Pruning is suppressed for the whole run, and nothing on disk moved. + expect(report.actions.some((action) => action.action === 'removed')).toBe(false); + expect(existsSync(path.join(stale, 'SKILL.md'))).toBe(true); + expect(await readFile(path.join(stale, 'SKILL.md'), 'utf8')).toBe('not ours to delete'); + }); + + it('resumes a store that gave up on a 422 when start is called again', async () => { + // A 422 is never retried on its own, but once the cause is fixed (a new key, + // or delivery enabled for the account) the application can resume the same + // store without restarting the process. + endpoint.defaultPollStatus = 422; + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + expect(store.start()).toBe(store); + expect(store.failed).toBeNull(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(store.getObject('skill', 'pdf-extraction')).not.toBeNull(); + }); + + it('points the give-up log line at start(), not only a process restart', async () => { + endpoint.defaultPollStatus = 422; + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(consoleErrors()).toMatch(/call start\(\) on this store/); + }); + + it('starts the failure count over when a store is restarted', async () => { + // Recoverable failures before the fatal one are history once delivery runs + // again, so a restarted store does not report them as its own. + endpoint.queuePoll([], { status: 500 }); + endpoint.defaultPollStatus = 422; + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.diagnostics.connectionFailures).toBe(1); + + endpoint.defaultPollStatus = 304; + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + store.start(); + expect(store.diagnostics.connectionFailures).toBe(0); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(store.failed).toBeNull(); + }); + + /** + * A response body that opens with `prefix` and then runs past the transport + * bound without a newline, in 4 MiB reads. Finite, a little past the bound, + * so an unbounded reader fails an assertion rather than the worker. + */ + const oversizedBody = (prefix: string): ReadableStream => { + const encoder = new TextEncoder(); + const filler = encoder.encode('x'.repeat(4 * 1024 * 1024)); + let remaining = Math.ceil(MAX_RESPONSE_CHARS / filler.length) + 2; + let opened = false; + return new ReadableStream({ + pull(controller) { + if (!opened) { + opened = true; + controller.enqueue(encoder.encode(prefix)); + } else if (remaining > 0) { + remaining -= 1; + controller.enqueue(filler); + } else { + controller.close(); + } + }, + }); + }; + + it('stops on a poll body over the memory bound, like a 422, after exactly one request (§3.25)', async () => { + // The payload's size belongs to the environment, not the connection, so + // the next request would be refused the same way: retried, it would + // re-download up to the bound on every backoff step, from every process. + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response(oversizedBody('{"events": ['), { status: 200 })); + const store = new FDv2SkillStore(SDK_KEY, { + baseUri: 'https://sdk.example.com', + mode: 'poll', + pollIntervalSeconds: 0.02, + initialBackoffSeconds: 0.005, + maxBackoffSeconds: 0.02, + }); + openStores.push(store); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 10 })).toBe(false); + expect(store.failed).toMatch(/transport bound/); + expect(store.diagnostics.lastError).toMatch(/transport bound/); + // Accounted as a fatal: it never retries, so it is not a recoverable failure. + expect(store.diagnostics.connectionFailures).toBe(0); + expect(store.isInitialized()).toBe(false); + // A window that would hold several retries at this store's 5ms backoff. + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(logged(warnSpy)).not.toMatch(/Skill delivery failed/); + expect(consoleErrors()).toMatch(/will not retry/); + }); + + it('stops on a streamed event over the memory bound and keeps serving what it held (§3.25)', async () => { + // One connection: a payload commits, then an event runs past the bound. + const committed = fullPayload([['put-object', putSkill()]]) + .map((event) => `event: ${event.event}\ndata: ${JSON.stringify(event.data ?? null)}\n\n`) + .join(''); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation( + async () => new Response(oversizedBody(`${committed}event: put-object\ndata: `), { status: 200 }), + ); + const store = new FDv2SkillStore(SDK_KEY, { + baseUri: 'https://sdk.example.com', + mode: 'stream', + initialBackoffSeconds: 0.005, + maxBackoffSeconds: 0.02, + }); + openStores.push(store); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 10 })).toBe(true); + expect(await waitUntil(() => store.failed !== null, 10_000)).toBe(true); + expect(store.failed).toMatch(/characters/); + expect(store.diagnostics.lastError).toBe(store.failed); + expect(store.diagnostics.connectionFailures).toBe(0); + // Last known good is still served. + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('makes backoff exponential and capped', () => { + expect(backoffDelayMs(1, 1000, 30_000, 0)).toBe(1000); + expect(backoffDelayMs(2, 1000, 30_000, 0)).toBe(2000); + expect(backoffDelayMs(3, 1000, 30_000, 0)).toBe(4000); + expect(backoffDelayMs(20, 1000, 30_000, 0)).toBe(30_000); + }); + + it('keeps backoff finite at any attempt number', () => { + // Retries are unbounded, so the attempt number is too, and `2 ** 9999` is + // `Infinity`. A zero base, where that would become `NaN`, is refused by the + // constructor (see 'rejects a non-positive or non-finite value'). + for (const jitter of [0, 0.5, 1]) { + const delay = backoffDelayMs(10_000, 1000, 30_000, jitter); + expect(Number.isFinite(delay)).toBe(true); + expect(delay).toBeLessThanOrEqual(30_000); + expect(delay).toBeGreaterThanOrEqual(0); + } + expect(backoffDelayMs(10_000, 1000, 30_000, 0)).toBe(30_000); + }); + + it('never lets jitter exceed the cap', () => { + for (let attempt = 1; attempt < 12; attempt += 1) { + for (let i = 0; i < 50; i += 1) { + const delay = backoffDelayMs(attempt, 1000, 5000); + expect(delay).toBeGreaterThanOrEqual(0); + expect(delay).toBeLessThanOrEqual(5000); + } + } + }); + + it('treats a malformed polling envelope as recoverable', () => { + expect(() => decodePollBody('{"nope":1}')).toThrow(/no 'events' array/); + expect(() => decodePollBody('not json')).toThrow(/not valid JSON/); + }); + + it('does not let a throwing listener kill delivery', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill('first')]])); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-changes')], + ['put-object', putSkill('second')], + ['payload-transferred', transferred('basis-2')], + ), + ); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.addListener(SKILL_OBJECT_KIND, () => { + throw new Error('boom'); + }); + store.start(); + expect(await waitUntil(() => store.getObject(SKILL_OBJECT_KIND, 'second') !== null)).toBe(true); + expect(store.failed).toBeNull(); + }); +}); + +// ─── The contentHash gap ───────────────────────────────────────────────────── + +describe('the missing contentHash', () => { + // The blocking backend gap, asserted as behaviour rather than assumed. An + // envelope with no `contentHash` must produce a *withheld* skill with the + // `missing_content_hash` reason — loudly, diagnosably, and without a crash. + // There is deliberately no fallback that skips verification: a hash the SDK + // computed from the content it was handed would certify the content against + // itself and verify nothing. + + const hashlessSummaries = (): string[] => + consoleErrors() + .split('\n') + .filter((line) => line.includes('No skill content will resolve')); + + it('withholds a hashless skill with the right reason', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill('pdf-extraction', { omitHash: true })]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const outcome = await getSkillResult('pdf-extraction'); + expect(outcome.skill).toBeNull(); + expect(outcome.reason).toBe('integrity_failure'); + expect(await getSkill('pdf-extraction')).toBeNull(); + expect(await allSkills()).toEqual([]); + }); + + it('still holds the object, so the outcome is not "absent"', async () => { + // Holding it is what makes the failure diagnosable. Dropping it at the + // transport would report `absent` — indistinguishable from "no such skill" — + // and would let a prune delete the last known-good copy already on disk. + endpoint.queuePoll(fullPayload([['put-object', putSkill('pdf-extraction', { omitHash: true })]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + const raw = store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction'); + expect(raw).not.toBeNull(); + expect('contentHash' in (raw as RawSkillObject)).toBe(false); + _setStore(store); + expect((await getSkillResult('pdf-extraction')).reason).not.toBe('absent'); + }); + + it('leaves the copy on disk alone when a hashless payload replaces a good one', async () => { + // The whole point of withholding, asserted through the reconcile rather than + // through the accessors — and the case the hash gap makes universal, since + // today *every* object arrives hashless. `writeSkills('*')` derives its prune + // keep-set from the keys `allObjects` is keyed by, so a key it cannot parse + // as a skill key drops out of the keep-set and prune deletes the last + // known-good copy. Which is the outcome this transport was written to avoid. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + + const store = pollStore({ pollIntervalSeconds: 0.02 }); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + const written = path.join(root, 'pdf-extraction', 'SKILL.md'); + await writeSkills('*', root); + expect(readFileSync(written, 'utf8')).toBe(SKILL_BODY); + + // Queued only now, and deliberately not up front. `waitForSkills` promises + // the *first* commit and nothing about the second, so a hashless payload + // waiting in the queue commits on the next poll — 20ms later — and a + // reconcile that has not run by then withholds every object and writes + // nothing. That is a real race the assertion above cannot survive, and it + // fails as an ENOENT on the read rather than as anything self-explanatory. + endpoint.queuePoll( + fullPayload([['put-object', putSkill('pdf-extraction', { objectVersion: 4, omitHash: true })]], 'basis-2'), + ); + expect(await waitUntil(() => store.diagnostics.hashlessObjects > 0, 5000)).toBe(true); + const report = await writeSkills('*', root); + + expect(existsSync(written)).toBe(true); + expect(readFileSync(written, 'utf8')).toBe(SKILL_BODY); + expect(report.actions.some((action) => action.action === 'removed')).toBe(false); + // Reported against the skill's own key, so the failure names the skill. + expect(report.actions.some((action) => action.action === 'error' && action.key === 'pdf-extraction')).toBe(true); + }); + + it('counts hashless objects', async () => { + endpoint.queuePoll( + fullPayload([ + ['put-object', putSkill('a', { omitHash: true })], + ['put-object', putSkill('b', { omitHash: true })], + ['put-object', putSkill('c')], + ]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(store.diagnostics.hashlessObjects).toBe(2); + expect(store.diagnostics.skillObjectsReceived).toBe(3); + }); + + it('logs an error naming the reason code', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill('pdf-extraction', { omitHash: true })]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + const errors = consoleErrors(); + expect(errors).toContain('missing_content_hash'); + expect(errors).toContain('pdf-extraction'); + expect(errors).toContain('contentHash'); + }); + + it('says so once when a whole payload is hashless', async () => { + endpoint.queuePoll( + fullPayload([ + ['put-object', putSkill('a', { omitHash: true })], + ['put-object', putSkill('b', { omitHash: true })], + ]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + const summaries = hashlessSummaries(); + expect(summaries).toHaveLength(1); + expect(summaries[0]).toContain('All 2 skill object(s)'); + }); + + it('does not repeat the summary for a store that has not moved', () => { + // `contentHash` is not on the wire yet, so a server answering `xfer-changes` + // rather than `xfer-none` re-delivers the same hashless payload on every + // poll. One problem is one paragraph, not one per interval. + const reader = new ProtocolReader(new SkillObjectSet()); + const payload = fullPayload([ + ['put-object', putSkill('a', { omitHash: true })], + ['put-object', putSkill('b', { omitHash: true })], + ]); + drive(reader, payload); + drive(reader, payload); + drive(reader, payload); + expect(hashlessSummaries()).toHaveLength(1); + }); + + it('speaks again when the hashless objects change', () => { + // A different set of withheld skills is a different problem. + const reader = new ProtocolReader(new SkillObjectSet()); + drive(reader, fullPayload([['put-object', putSkill('a', { omitHash: true })]])); + drive(reader, fullPayload([['put-object', putSkill('b', { omitHash: true })]], 'basis-2')); + expect(hashlessSummaries()).toHaveLength(2); + }); + + it('speaks again about a relapse after a recovery', () => { + const reader = new ProtocolReader(new SkillObjectSet()); + const broken = fullPayload([['put-object', putSkill('a', { omitHash: true })]]); + drive(reader, broken); + drive(reader, fullPayload([['put-object', putSkill('a')]], 'basis-2')); + drive(reader, broken); + expect(hashlessSummaries()).toHaveLength(2); + }); + + it('bounds what it remembers about hashless objects', () => { + // One entry per `(key, version)` for the life of the process would leak + // slowly in an agent whose skills are versioned often. + const reader = new ProtocolReader(new SkillObjectSet()); + for (let version = 1; version <= 600; version += 1) { + drive( + reader, + fullPayload( + [['put-object', putSkill('pdf-extraction', { objectVersion: version, omitHash: true })]], + 'basis-1', + ), + ); + } + expect(reader._warnedHashless.size).toBeLessThan(600); + }); + + it('forgets what it remembers once everything held verifies', () => { + const reader = new ProtocolReader(new SkillObjectSet()); + drive(reader, fullPayload([['put-object', putSkill('a', { omitHash: true })]])); + expect(reader._warnedHashless.size).toBeGreaterThan(0); + drive(reader, fullPayload([['put-object', putSkill('a')]], 'basis-2')); + expect(reader._warnedHashless.size).toBe(0); + }); + + it('remembers per reader, so two stores in one process do not cross-talk', () => { + // The dedupe memory is held by the reader, not the module: a second store + // in the same process reporting the same hashless object is a second + // deployment problem, and must be told about it. + const first = new ProtocolReader(new SkillObjectSet()); + drive(first, fullPayload([['put-object', putSkill('a', { omitHash: true })]])); + const before = errorSpy.mock.calls.length; + const second = new ProtocolReader(new SkillObjectSet()); + drive(second, fullPayload([['put-object', putSkill('a', { omitHash: true })]])); + expect(errorSpy.mock.calls.length).toBeGreaterThan(before); + expect(first._warnedHashless).not.toBe(second._warnedHashless); + }); + + it('still reports each hashless object in a payload separately', () => { + // The per-object dedupe the bound and the summary must not disturb. + const reader = new ProtocolReader(new SkillObjectSet()); + drive( + reader, + fullPayload([ + ['put-object', putSkill('a', { omitHash: true })], + ['put-object', putSkill('b', { omitHash: true })], + ]), + ); + const perObject = consoleErrors() + .split('\n') + .filter((line) => line.includes('arrived without a contentHash and will be withheld')); + expect(perObject).toHaveLength(2); + }); + + it('does not claim total failure for a partly hashed payload', async () => { + endpoint.queuePoll( + fullPayload([ + ['put-object', putSkill('a', { omitHash: true })], + ['put-object', putSkill('b')], + ]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(consoleErrors()).not.toContain('No skill content will resolve'); + }); + + it('distinguishes a mismatched hash from a missing one', async () => { + // `missing_content_hash` and `hash_mismatch` must not collapse: one is a + // backend gap and the other is possible tampering. + endpoint.queuePoll( + fullPayload([['put-object', putSkill('pdf-extraction', { contentHash: hash('something else') })]]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + expect(store.diagnostics.hashlessObjects).toBe(0); + _setStore(store); + expect((await getSkillResult('pdf-extraction')).reason).toBe('integrity_failure'); + }); + + it('resolves a hashed skill end to end', async () => { + // The positive control: everything above is a gap, not a broken adapter. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const skill = await getSkill('pdf-extraction'); + expect(skill?.key).toBe('pdf-extraction'); + expect(skill?.version).toBe(3); + expect(new TextDecoder().decode(skill?.content)).toBe(SKILL_BODY); + expect(skill?.contentHash).toBe(hash(SKILL_BODY)); + expect(skill?.name).toBe('PDF Extraction'); + }); + + it('resolves a pinned reference to the pinned version', async () => { + endpoint.queuePoll( + fullPayload([ + ['put-object', putSkill('pdf-extraction', { objectVersion: 2, content: 'v2 body' })], + ['put-object', putSkill('pdf-extraction', { objectVersion: 5, content: 'v5 body' })], + ]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const pinned = await getSkill('pdf-extraction', { version: 2 }); + expect(new TextDecoder().decode(pinned?.content)).toBe('v2 body'); + expect((await getSkill('pdf-extraction'))?.version).toBe(5); + }); + + it('does not resolve the payload version as a skill version', async () => { + // The end-to-end form of the wire-key/version assertion. Asking for the + // payload version resolves nothing — reported `absent`, because the store + // answers "I hold no such version" rather than answering with the wrong one. + endpoint.queuePoll( + fullPayload([['put-object', putSkill('pdf-extraction', { objectVersion: 3, payloadVersion: 42 })]]), + ); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const byPayloadVersion = await getSkillResult('pdf-extraction', { version: 42 }); + expect(byPayloadVersion.skill).toBeNull(); + expect(byPayloadVersion.reason).toBe('absent'); + expect(await getSkill('pdf-extraction', { version: 3 })).not.toBeNull(); + }); +}); + +// ─── Server-side only ──────────────────────────────────────────────────────── + +describe('server-side only', () => { + it('refuses a mobile key', () => { + expect(() => new FDv2SkillStore('mob-00000000-0000-4000-8000-000000000000')).toThrow(/mobile key/); + }); + + it('refuses a client-side environment ID', () => { + expect(() => new FDv2SkillStore('0123456789abcdef01234567')).toThrow(/client-side/); + }); + + it('refuses an empty credential', () => { + expect(() => new FDv2SkillStore(' ')).toThrow(/server-side SDK key/); + }); + + it('accepts a server-side key', () => { + expect(new FDv2SkillStore(SDK_KEY)).toBeTruthy(); + }); + + it('warns but allows an unrecognised credential shape', () => { + // Private instances and test doubles issue keys without the public prefix. + new FDv2SkillStore('my-private-instance-credential'); + expect(logged(warnSpy)).toContain('server-side SDK key'); + }); + + it('refuses an unknown mode', () => { + expect(() => new FDv2SkillStore(SDK_KEY, { mode: 'mobile' as never })).toThrow(/stream/); + }); +}); + +// ─── The eager re-reconcile ────────────────────────────────────────────────── + +describe('watchSkills', () => { + it('prunes a revoked skill without a restart', async () => { + // The store's change listener drives the reconcile, so the file goes away + // seconds after the delete-object rather than at the next process start. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill()], + ['payload-transferred', transferred('basis-2')], + ), + ); + endpoint.queuePoll([], { status: 304 }); + + const store = pollStore({ pollIntervalSeconds: 0.1 }); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + const { report, watcher } = await watchSkills('*', root, { debounceMs: 20 }); + try { + const written = path.join(root, 'pdf-extraction', 'SKILL.md'); + expect(await readFile(written, 'utf8')).toBe(SKILL_BODY); + expect(report.actions.some((a) => a.action === 'written')).toBe(true); + expect(await waitUntil(() => !existsSync(written), 10_000)).toBe(true); + } finally { + await watcher.close(); + } + }); + + it('rewrites a new version without a restart', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill('pdf-extraction', { content: 'first' })]])); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-full')], + ['put-object', putSkill('pdf-extraction', { objectVersion: 4, content: 'second' })], + ['payload-transferred', transferred('basis-2')], + ), + ); + endpoint.queuePoll([], { status: 304 }); + + const store = pollStore({ pollIntervalSeconds: 0.1 }); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills('*', root, { debounceMs: 20 }); + try { + const written = path.join(root, 'pdf-extraction', 'SKILL.md'); + expect(await readFile(written, 'utf8')).toBe('first'); + expect(await waitUntil(() => existsSync(written) && readFileSync(written, 'utf8') === 'second', 10_000)).toBe( + true, + ); + } finally { + await watcher.close(); + } + }); + + it('coalesces a burst of changes into exactly one reconcile', async () => { + // To discriminate rather than pass vacuously: the burst is queued only + // after the watcher registers, the assertion is an exact count that must + // have moved, and the notifications are spread over time. A burst in one + // synchronous listener run collapses to one reconcile via + // `SkillWatcher.schedule`'s `pending` flag with or without a debounce, so + // three commits arrive a poll apart and only the debounce window can merge + // them. + endpoint.queuePoll(fullPayload([['put-object', putSkill('seed')]])); + const store = pollStore({ pollIntervalSeconds: 0.02 }); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + // Comfortably longer than the three commits take to arrive at a 20 ms poll + // interval — with enough margin that a loaded CI runner cannot push the + // third commit out of the window and turn a correct implementation red. + const { watcher } = await watchSkills('*', root, { debounceMs: 1000 }); + try { + // The initial reconcile is the caller's own result and is excluded from + // the counter, so this is the baseline the equality is measured against. + expect(watcher.reconciles).toBe(0); + + // Twelve objects across three commits. Twelve, because a commit notifies + // once per *changed object* rather than once per commit — which is + // why the debounce exists at all rather than being a refinement of a + // per-commit notification. + for (let commit = 0; commit < 3; commit += 1) { + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-changes')], + ...Array.from( + { length: 4 }, + (_, i) => ['put-object', putSkill(`skill-${commit}-${i}`)] as [string, unknown], + ), + ['payload-transferred', transferred(`basis-${commit + 2}`)], + ), + ); + } + + // The endpoint really served the changes rather than a steady 304 for the + // duration — the other way this test goes vacuous. Asserted on the store's + // own counters, so "nothing was delivered" cannot read as "delivery was + // coalesced". + expect(await waitUntil(() => store.diagnostics.skillObjectsReceived >= 13, 10_000)).toBe(true); + expect(store.diagnostics.payloadsTransferred).toBeGreaterThanOrEqual(4); + + // The counter moved... + expect(await waitUntil(() => watcher.reconciles > 0, 10_000)).toBe(true); + // ...and then settled at exactly one. Waited out past a further whole + // window, so a second reconcile would have landed by now. + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(watcher.reconciles).toBe(1); + // And the one reconcile saw the settled state — the last commit's objects + // included — rather than a half-applied burst. + expect(await readFile(path.join(root, 'skill-2-3', 'SKILL.md'), 'utf8')).toBe(SKILL_BODY); + } finally { + await watcher.close(); + } + }); + + it('keeps last known good during an outage', async () => { + // `onUnavailable: 'keep'` is the endorsed default: an outage must not read as + // "everything was revoked". + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll([], { status: 500 }); + const store = pollStore({ pollIntervalSeconds: 0.02 }); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills('*', root, { debounceMs: 20 }); + try { + const written = path.join(root, 'pdf-extraction', 'SKILL.md'); + expect(await readFile(written, 'utf8')).toBe(SKILL_BODY); + // `lastError` rather than `connectionFailures`: the counter resets on the + // next successful poll, so asserting on it races the retry. + expect(await waitUntil(() => store.diagnostics.lastError !== null, 10_000)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(await readFile(written, 'utf8')).toBe(SKILL_BODY); + } finally { + await watcher.close(); + } + }); + + it('prunes when a full transfer drops every skill', async () => { + // A full transfer revokes by omission, so this payload carries no + // `delete-object` at all. The commit still has to wake the watcher, or the + // revoked file sits on disk until the process restarts. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll( + events(['server-intent', serverIntent('xfer-full')], ['payload-transferred', transferred('basis-2')]), + ); + endpoint.queuePoll([], { status: 304 }); + + const store = pollStore({ pollIntervalSeconds: 0.1 }); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills('*', root, { debounceMs: 20 }); + try { + const written = path.join(root, 'pdf-extraction', 'SKILL.md'); + expect(readFileSync(written, 'utf8')).toBe(SKILL_BODY); + expect(await waitUntil(() => !existsSync(written), 10_000)).toBe(true); + } finally { + await watcher.close(); + } + }); + + it('reconciles again for a change that commits during the initial write', async () => { + // The listener is registered before the initial reconcile, so a payload that + // commits while that reconcile is doing its filesystem I/O is not lost. The + // fake store fires its listener from inside `allObjects` — that is, from + // inside the initial reconcile — and answers with the set as it was, so the + // second version only reaches disk if the watcher heard about it. + const verified = (content: string): RawSkillObject => ({ + key: 'a', + version: 1, + content, + contentHash: hash(content), + }); + + let fire: (() => void) | null = null; + let objects: Record = { a: verified('first') }; + let fired = false; + _setStore({ + getObject: (_kind: string, key: string) => objects[key] ?? null, + allObjects: () => { + const current = objects; + if (!fired && fire) { + fired = true; + objects = { a: verified('second') }; + fire(); + } + return current; + }, + addListener: (_kind: string, fn: () => void) => { + fire = fn; + }, + removeListener: () => { + fire = null; + }, + }); + + const root = path.join(await scratchRoot(), 'skills'); + const { report, watcher } = await watchSkills('*', root, { debounceMs: 10 }); + try { + expect(fired).toBe(true); + expect(report.actions.some((action) => action.action === 'written')).toBe(true); + const written = path.join(root, 'a', 'SKILL.md'); + expect(await waitUntil(() => readFileSync(written, 'utf8') === 'second', 5000)).toBe(true); + } finally { + await watcher.close(); + } + }); + + it('returns a named object, not a tuple, and takes its debounce in milliseconds', async () => { + // Both differ from Python, and both go wrong silently in a port: destructuring `[report, watcher]` off an object yields + // `undefined`s, and passing Python's seconds value to `debounceMs` sets a + // 0.5 ms window that looks like a timing bug rather than a unit bug. + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content: 'body', contentHash: hash('body') }); + _setStore(store); + + const result = await watchSkills('*', path.join(await scratchRoot(), 'skills'), { + // Milliseconds. A whole second here, so the assertion below cannot be + // satisfied by a window that has already elapsed. + debounceMs: 1000, + }); + try { + expect(Object.keys(result).sort()).toEqual(['report', 'watcher']); + expect(Array.isArray(result)).toBe(false); + expect(result.report.actions.some((action) => action.action === 'written')).toBe(true); + expect(result.watcher).toBeInstanceOf(SkillWatcher); + + // The unit, observably: a change notified now has not reconciled a + // quarter of a second later, because the window is 1000 ms and not 1. + store.put({ key: 'a', version: 2, content: 'new body', contentHash: hash('new body') }); + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(result.watcher.reconciles).toBe(0); + } finally { + await result.watcher.close(); + } + }); + + it('rejects a negative debounce and a non-finite one', async () => { + // `NaN` is the case a `< 0` guard misses — `NaN < 0` is false — and it is + // not benign: `setTimeout(fn, NaN)` fires at 1 ms, collapsing the window so + // that every delivered object reconciles and nothing coalesces at all. So + // the guard has to reject it the way `writeSkills` already rejects a + // non-numeric `timeout`. + const store = new InMemorySkillStore(); + _setStore(store); + const root = path.join(await scratchRoot(), 'skills'); + const listeners = (): unknown[] => + (store as unknown as { listeners: Map }).listeners.get(SKILL_OBJECT_KIND) ?? []; + + for (const debounceMs of [-1, Number.NaN, Number.POSITIVE_INFINITY]) { + await expect(watchSkills('*', root, { debounceMs })).rejects.toThrow(/debounceMs/); + } + // Refused before anything was registered or written: the validation runs + // ahead of the listener and ahead of the initial reconcile. + expect(listeners()).toEqual([]); + expect(existsSync(root)).toBe(false); + }); + + it('calls onReconcile for a delivery-triggered reconcile and not for the initial one', async () => { + // The initial report is returned directly, so delivering it through the + // callback as well would make a caller handle the same reconcile twice. + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content: 'first', contentHash: hash('first') }); + _setStore(store); + + const seen: ReconcileReport[] = []; + const root = path.join(await scratchRoot(), 'skills'); + const { report, watcher } = await watchSkills('*', root, { + debounceMs: 20, + onReconcile: (each) => { + seen.push(each); + }, + }); + try { + // The initial reconcile did happen — it wrote the file — and it did not + // reach the callback. + expect(report.actions.some((action) => action.action === 'written')).toBe(true); + expect(await readFile(path.join(root, 'a', 'SKILL.md'), 'utf8')).toBe('first'); + expect(seen).toEqual([]); + + store.put({ key: 'a', version: 2, content: 'second', contentHash: hash('second') }); + + expect(await waitUntil(() => seen.length > 0, 10_000)).toBe(true); + expect(seen).toHaveLength(1); + // A real report for the re-reconcile, not the initial one handed over a + // second time. + expect(seen[0]).not.toBe(report); + expect(seen[0].actions.map((action) => [action.key, action.action])).toContainEqual(['a', 'updated']); + expect(seen[0].ok).toBe(true); + } finally { + await watcher.close(); + } + }); + + it('survives a reconcile that throws — the next notification still reconciles', async () => { + // A watcher that died on one bad run would silently stop pruning, which is + // strictly worse than a noisy one: revocations would keep arriving and + // nothing would act on them, and the process would look healthy. + // + // `onUnavailable: 'raise'` against a store that throws is the shortest real + // path to a *throwing* reconcile — every ordinary failure is reported as an + // `error` action instead. + const body = (content: string): RawSkillObject => ({ + key: 'a', + version: 1, + content, + contentHash: hash(content), + }); + let explode = false; + let notify: (() => void) | null = null; + _setStore({ + getObject: () => { + if (explode) throw new Error('transport failure'); + return body('first'); + }, + allObjects: () => ({ 'a:1': body('first') }), + addListener: (_kind: string, fn: () => void) => { + notify = fn; + }, + removeListener: () => { + notify = null; + }, + }); + + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills([{ key: 'a', version: 1 }], root, { + debounceMs: 10, + onUnavailable: 'raise', + }); + try { + expect(await readFile(path.join(root, 'a', 'SKILL.md'), 'utf8')).toBe('first'); + expect(notify).not.toBeNull(); + + explode = true; + (notify as unknown as () => void)(); + + // The failure is visible rather than swallowed, and the watcher says so. + expect(await waitUntil(() => /the watcher continues/.test(consoleErrors()), 10_000)).toBe(true); + // The throwing run does not count as a completed reconcile. + expect(watcher.reconciles).toBe(0); + + explode = false; + (notify as unknown as () => void)(); + + expect(await waitUntil(() => watcher.reconciles > 0, 10_000)).toBe(true); + } finally { + await watcher.close(); + } + }); + + it('raises an invalid root out of watchSkills rather than inside a worker', async () => { + // The initial reconcile runs on the caller's thread precisely so this is a + // rejected promise the caller can catch, exactly as `writeSkills` would give + // them — not a line in a background task's log that a caller cannot see and + // cannot react to. + const store = new InMemorySkillStore(); + _setStore(store); + const scratch = await scratchRoot(); + + // A root whose parent does not exist: never created recursively. + await expect(watchSkills('*', path.join(scratch, 'a', 'b', 'c'))).rejects.toThrow(); + // And a root that is a file rather than a directory. + const file = path.join(scratch, 'file'); + await writeFile(file, '', 'utf8'); + await expect(watchSkills('*', file)).rejects.toThrow(); + + // Nothing was logged instead of thrown — the failure travelled one way only. + expect(consoleErrors()).toBe(''); + }); + + it('leaves no listener behind when the initial reconcile fails', async () => { + // The listener is registered *before* the initial reconcile so a payload + // committing during its filesystem I/O is not lost — which means a reconcile + // that throws has to detach on the way out. The caller is handed an + // exception, not a watcher to close, so nothing else can. + const store = new InMemorySkillStore(); + _setStore(store); + const listeners = (): unknown[] => + (store as unknown as { listeners: Map }).listeners.get(SKILL_OBJECT_KIND) ?? []; + const notADirectory = path.join(await scratchRoot(), 'file'); + await writeFile(notADirectory, '', 'utf8'); + + await expect(watchSkills('*', notADirectory)).rejects.toThrow(); + + expect(listeners()).toEqual([]); + // And the store holding no reference to it is the observable that matters: a + // later change must not reach a watcher nobody can close. + store.put({ key: 'a', version: 1, content: 'body', contentHash: hash('body') }); + expect(listeners()).toEqual([]); + }); + + it('refuses a store with no addListener, loudly', async () => { + _setStore({ + getObject: () => null, + allObjects: () => ({}), + }); + // The message names both remedies: the one-shot reconcile, and the store + // that does implement the listener half of the seam. + await expect(watchSkills('*', await scratchRoot())).rejects.toThrow(/writeSkills[\s\S]*FDv2SkillStore/); + }); + + it('passes prune and timeout straight through to writeSkills (§3.26)', async () => { + // Driven behaviourally rather than by spying on the import: a `prune: false` + // that reached `writeSkills` leaves a stale managed skill alone on the + // initial reconcile *and* on a re-reconcile, and a `timeout: 0` that reached + // it exhausts before retrieval — both are outcomes only `writeSkills` + // produces. + const seed = new InMemorySkillStore(); + seed.put({ key: 'a', version: 1, content: 'first', contentHash: hash('first') }); + seed.put({ key: 'stale', version: 1, content: 'old', contentHash: hash('old') }); + _setStore(seed); + const root = path.join(await scratchRoot(), 'skills'); + expect((await writeSkills('*', root)).ok).toBe(true); + // Now a store that no longer holds `stale`: with pruning on it would be removed. + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content: 'first', contentHash: hash('first') }); + _setStore(store); + + const { report, watcher } = await watchSkills('*', root, { debounceMs: 10, prune: false }); + try { + expect(report.ok).toBe(true); + expect(report.actions.some((a) => a.action === 'removed')).toBe(false); + expect(await readFile(path.join(root, 'stale', 'SKILL.md'), 'utf8')).toBe('old'); + + store.put({ key: 'a', version: 2, content: 'second', contentHash: hash('second') }); + expect(await waitUntil(() => watcher.reconciles === 1, 10_000)).toBe(true); + expect(await readFile(path.join(root, 'stale', 'SKILL.md'), 'utf8')).toBe('old'); + } finally { + await watcher.close(); + } + + const timed = await watchSkills([{ key: 'a', version: 2 }], root, { debounceMs: 10, timeout: 0 }); + try { + expect(timed.report.ok).toBe(false); + expect(timed.report.errors.map((a) => a.error).join('\n')).toMatch(/timeout was exhausted/); + } finally { + await timed.watcher.close(); + } + }); + + it('close() survives a store whose removeListener throws, and stays closed', async () => { + // Detaching is best effort: a store that cannot detach must not leave the + // watcher half-closed with its timer armed, and a second close is a no-op. + let notify: (() => void) | null = null; + _setStore({ + getObject: () => null, + allObjects: () => ({}), + addListener: (_kind: string, fn: () => void) => { + notify = fn; + }, + removeListener: () => { + throw new Error('cannot detach'); + }, + }); + const { watcher } = await watchSkills('*', path.join(await scratchRoot(), 'skills'), { debounceMs: 10 }); + await watcher.close(); + expect(consoleErrors()).toContain('cannot detach'); + const errorsAfterClose = errorSpy.mock.calls.length; + await watcher.close(); + expect(errorSpy.mock.calls.length).toBe(errorsAfterClose); + // Closed: a notification that still reaches it schedules nothing. + (notify as unknown as () => void)(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(watcher.reconciles).toBe(0); + }); + + it('logs an async onReconcile that rejects, and the next commit still reconciles', async () => { + // `onReconcile` may be async. A rejection must be caught and logged like a + // synchronous throw — not left as an unhandled rejection — and must not + // stop the watcher. + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content: 'first', contentHash: hash('first') }); + _setStore(store); + let calls = 0; + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills('*', root, { + debounceMs: 10, + onReconcile: async () => { + calls += 1; + if (calls === 1) throw new Error('async callback exploded'); + }, + }); + try { + store.put({ key: 'a', version: 2, content: 'second', contentHash: hash('second') }); + expect(await waitUntil(() => /async callback exploded/.test(consoleErrors()), 10_000)).toBe(true); + expect(consoleErrors()).toContain('callback threw'); + store.put({ key: 'a', version: 3, content: 'third', contentHash: hash('third') }); + expect(await waitUntil(() => watcher.reconciles === 2, 10_000)).toBe(true); + expect(calls).toBe(2); + expect(await readFile(path.join(root, 'a', 'SKILL.md'), 'utf8')).toBe('third'); + } finally { + await watcher.close(); + } + }); + + it('throws when no store is configured', async () => { + await expect(watchSkills('*', await scratchRoot())).rejects.toThrow(/configured skill store/); + }); + + it('can also be driven by the in-memory store', async () => { + // The watcher is wired to the seam, not to the FDv2 store. + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content: 'body', contentHash: hash('body') }); + _setStore(store); + + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills('*', root, { debounceMs: 20 }); + try { + const written = path.join(root, 'a', 'SKILL.md'); + expect(await readFile(written, 'utf8')).toBe('body'); + store.put({ key: 'a', version: 2, content: 'new body', contentHash: hash('new body') }); + expect(await waitUntil(() => existsSync(written) && readFileSync(written, 'utf8') === 'new body', 10_000)).toBe( + true, + ); + } finally { + await watcher.close(); + } + }); + + it('detaches from the store on close, so a closed watcher is no longer notified', async () => { + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content: 'first', contentHash: hash('first') }); + _setStore(store); + const listeners = (): unknown[] => + (store as unknown as { listeners: Map }).listeners.get(SKILL_OBJECT_KIND) ?? []; + + const root = path.join(await scratchRoot(), 'skills'); + const { watcher } = await watchSkills('*', root, { debounceMs: 20 }); + expect(listeners()).toContain(watcher.notify); + + await watcher.close(); + + expect(listeners()).not.toContain(watcher.notify); + store.put({ key: 'a', version: 4, content: 'second', contentHash: hash('second') }); + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(await readFile(path.join(root, 'a', 'SKILL.md'), 'utf8')).toBe('first'); + expect(watcher.reconciles).toBe(0); + }); + + it('leaves no listeners behind across repeated watchers', async () => { + const store = new InMemorySkillStore(); + _setStore(store); + const listeners = (): unknown[] => + (store as unknown as { listeners: Map }).listeners.get(SKILL_OBJECT_KIND) ?? []; + const root = path.join(await scratchRoot(), 'skills'); + for (let i = 0; i < 5; i += 1) { + const { watcher } = await watchSkills('*', root, { debounceMs: 20 }); + expect(listeners()).toHaveLength(1); + await watcher.close(); + } + expect(listeners()).toEqual([]); + }); + + it('still closes against a store with no removeListener', async () => { + // `removeListener` is optional: an older store keeps working, at the cost + // of the listener staying registered. + const registered: unknown[] = []; + _setStore({ + getObject: () => null, + allObjects: () => ({}), + addListener: (_kind, fn) => { + registered.push(fn); + }, + }); + const { watcher } = await watchSkills('*', path.join(await scratchRoot(), 'skills'), { debounceMs: 20 }); + expect(registered).toEqual([watcher.notify]); + await watcher.close(); + await watcher.close(); + expect(registered).toEqual([watcher.notify]); + }); + + it('treats removing an unregistered listener from the FDv2 store as a no-op', () => { + const store = pollStore(); + const fn = (): void => {}; + store.removeListener(SKILL_OBJECT_KIND, fn); + store.addListener(SKILL_OBJECT_KIND, fn); + store.removeListener('flag', fn); + store.removeListener(SKILL_OBJECT_KIND, fn); + store.removeListener(SKILL_OBJECT_KIND, fn); + const listeners = (store as unknown as { listeners: Map }).listeners; + expect(listeners.get(SKILL_OBJECT_KIND)).toEqual([]); + }); + + it('refuses a listener for a kind the FDv2 store cannot notify', () => { + const store = pollStore(); + expect(() => store.addListener('flag', vi.fn())).toThrow(/never fire/); + expect(() => store.addListener('segment', vi.fn())).toThrow(/never fire/); + }); + + it('retains no listener it refused, so nothing is left to leak', () => { + const store = pollStore(); + expect(() => store.addListener('flag', vi.fn())).toThrow(); + const listeners = (store as unknown as { listeners: Map }).listeners; + expect(listeners.has('flag')).toBe(false); + }); + + it('still lets a refused listener be detached unconditionally', async () => { + // `SkillWatcher.close` removes without knowing whether the add succeeded. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + const fn = vi.fn(); + expect(() => store.addListener('flag', fn)).toThrow(); + expect(() => store.removeListener('flag', fn)).not.toThrow(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(fn).not.toHaveBeenCalled(); + }); +}); + +// ─── Lifecycle ─────────────────────────────────────────────────────────────── + +describe('lifecycle', () => { + it('makes start idempotent', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + expect(store.start()).toBe(store); + expect(store.start()).toBe(store); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + }); + + it('makes close idempotent', async () => { + const store = pollStore(); + store.start(); + await store.close(); + await store.close(); + }); + + it('keeps answering from what it received after close', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + await store.waitForSkills({ timeoutSeconds: 5 }); + await store.close(); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('isInitialized tracks the first payload, and stays true after close (§3.25)', async () => { + // The probe `writeSkills('*')` reads to decide whether it may prune. Before + // the first payload, an empty store and an environment with no skills are + // the same answer through `allObjects`; this is what tells them apart. + const silent = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 60, + requester: new ScriptedRequester([new Promise(() => {})]), + }); + openStores.push(silent); + expect(silent.isInitialized()).toBe(false); + silent.start(); + expect(await silent.waitForSkills({ timeoutSeconds: 0.05 })).toBe(false); + expect(silent.isInitialized()).toBe(false); + + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const delivering = pollStore(); + delivering.start(); + expect(await delivering.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(delivering.isInitialized()).toBe(true); + await delivering.close(); + // Content outlives the connection, so the fact about it does too. + expect(delivering.isInitialized()).toBe(true); + }); + + it('stays initialized across a 304 that confirms the payload held', async () => { + // The 304 the store can actually reach: it offers an etag earned by a body it + // applied in full, and the answer confirms that content is still current. A + // payload arrived, and a 304 does not take that back. + endpoint.queuePoll(fullPayload([['put-object', putSkill()]], 'basis-1'), { etag: 'W/"v1"' }); + // The unconditional poll that follows a commit, answered `none`: a completed + // exchange, so its etag is the one the third request gets to offer. + endpoint.queuePoll(events(['server-intent', serverIntent('none')]), { etag: 'W/"v2"' }); + endpoint.queuePoll([], { status: 304 }); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => endpoint.requests.length >= 3)).toBe(true); + expect(endpoint.requests[2].ifNoneMatch).toBe('W/"v2"'); + expect(store.isInitialized()).toBe(true); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + }); + + it('times out waitForSkills rather than hanging', async () => { + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 60, + requester: new ScriptedRequester([new Promise(() => {})]), + }); + openStores.push(store); + expect(await store.waitForSkills({ timeoutSeconds: 0.05 })).toBe(false); + }); + + it('retains no waiter for a wait that timed out', async () => { + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 60, + requester: new ScriptedRequester([new Promise(() => {})]), + }); + openStores.push(store); + const waiters = (store as unknown as { firstPayloadWaiters: unknown[] }).firstPayloadWaiters; + for (let i = 0; i < 3; i += 1) expect(await store.waitForSkills({ timeoutSeconds: 0.01 })).toBe(false); + // Every timed-out wait left behind is retained for the store's lifetime. + expect(waiters).toHaveLength(0); + }); + + /** + * Runs a standalone script against the store's source in a fresh `node`, and + * returns its exit code and stdout. The test runner keeps its own event loop + * alive, so whether a pending wait holds the process up is only observable in + * a process that has nothing else to do. + */ + const runStandalone = async ( + file: string, + source: string, + ): Promise<{ code: number | null; stdout: string; stderr: string }> => { + const root = await scratchRoot(); + const script = path.join(root, file); + const storeUrl = pathToFileURL(fileURLToPath(new URL('../skills-fdv2.ts', import.meta.url))).href; + await writeFile(script, source.replaceAll('STORE_URL', JSON.stringify(storeUrl))); + const tsx = path.join(path.dirname(createRequire(import.meta.url).resolve('tsx/package.json')), 'dist/cli.mjs'); + return new Promise((resolve) => { + execFile(process.execPath, [tsx, script], { timeout: 20_000 }, (failure, stdout, stderr) => { + const code = failure === null ? 0 : typeof failure.code === 'number' ? failure.code : null; + resolve({ code, stdout, stderr }); + }); + }); + }; + + // A store whose delivery is failing recoverably holds nothing that keeps the + // process up: the backoff sleep is unreffed, and a refused connection leaves no + // socket open. Only the wait's own timer can. + const UNREACHABLE = `{ mode: 'poll', baseUri: 'http://127.0.0.1:1' }`; + const WAIT_MS = 800; + + it.each([ + [ + 'ESM top-level await', + 'wait.mts', + `import { FDv2SkillStore } from STORE_URL; +const store = new FDv2SkillStore('${SDK_KEY}', ${UNREACHABLE}).start(); +const started = Date.now(); +const ready = await store.waitForSkills({ timeoutSeconds: ${WAIT_MS / 1000} }); +console.log(JSON.stringify({ ready, elapsed: Date.now() - started })); +await store.close();`, + ], + [ + 'a CommonJS async function', + 'wait.cts', + `(async () => { + const { FDv2SkillStore } = await import(STORE_URL); + const store = new FDv2SkillStore('${SDK_KEY}', ${UNREACHABLE}).start(); + const started = Date.now(); + const ready = await store.waitForSkills({ timeoutSeconds: ${WAIT_MS / 1000} }); + console.log(JSON.stringify({ ready, elapsed: Date.now() - started })); + await store.close(); +})();`, + ], + [ + 'a store that was never started', + 'never-started.mts', + `import { FDv2SkillStore } from STORE_URL; +const store = new FDv2SkillStore('${SDK_KEY}', ${UNREACHABLE}); +const started = Date.now(); +const ready = await store.waitForSkills({ timeoutSeconds: ${WAIT_MS / 1000} }); +console.log(JSON.stringify({ ready, elapsed: Date.now() - started }));`, + ], + ])( + 'keeps a standalone process alive until waitForSkills times out (%s)', + async (_label, file, source) => { + // An unreffed wait let `node` exit mid-await: ESM top-level await exited 13, + // and CommonJS exited 0 without running the line after the `await`. + const { code, stdout, stderr } = await runStandalone(file, source); + expect({ code, stderr: code === 0 ? '' : stderr }).toEqual({ code: 0, stderr: '' }); + const line = stdout + .split('\n') + .find((l) => l.startsWith('{')) + ?.trim(); + expect(line, `no result line in stdout: ${stdout}`).toBeDefined(); + const result = JSON.parse(line ?? '{}') as { ready: boolean; elapsed: number }; + expect(result.ready).toBe(false); + // Resolved by the timeout, not by anything else ending the wait early. + expect(result.elapsed).toBeGreaterThanOrEqual(WAIT_MS - 50); + }, + 30_000, + ); + + it('resolves waitForSkills false at once for a wait started after close', async () => { + // A connection that is open and has delivered nothing: the store is neither + // holding a payload nor has it given up, so a wait must not run its + // timeout out in full. + endpoint.holdStreamOpen = true; + endpoint.queueStream([]); + const store = streamStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length > 0)).toBe(true); + await store.close(); + const started = Date.now(); + expect(await store.waitForSkills({ timeoutSeconds: 3 })).toBe(false); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it('resolves waitForSkills false at once for a store closed before it started', async () => { + const store = pollStore(); + await store.close(); + const started = Date.now(); + expect(await store.waitForSkills({ timeoutSeconds: 3 })).toBe(false); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it('does not report a clean close as a delivery failure', async () => { + endpoint.holdStreamOpen = true; + endpoint.queueStream([]); + const store = streamStore(); + store.start(); + expect(await waitUntil(() => endpoint.requests.length > 0)).toBe(true); + await store.close(); + expect(await store.waitForSkills({ timeoutSeconds: 0.05 })).toBe(false); + expect(store.failed).toBeNull(); + }); + + it('still answers waitForSkills true after close when a payload arrived', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + await store.close(); + expect(await store.waitForSkills({ timeoutSeconds: 3 })).toBe(true); + }); + + it('refuses to restart a closed store', async () => { + const store = pollStore(); + store.start(); + await store.close(); + expect(() => store.start()).toThrow(/cannot be restarted/); + }); + + it('opens no second delivery loop once closed', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore({ pollIntervalSeconds: 60 }); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + await store.close(); + const requests = endpoint.requests.length; + // The refusal itself is asserted above; what matters here is that the store + // opened nothing, rather than throwing after starting a second loop. + try { + store.start(); + } catch { + /* expected */ + } + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(endpoint.requests).toHaveLength(requests); + }); + + it('refuses to restart a store closed before it ever started', async () => { + const store = pollStore(); + await store.close(); + expect(() => store.start()).toThrow(/cannot be restarted/); + }); + + it('satisfies the seam before it starts', () => { + const store = new FDv2SkillStore(SDK_KEY); + expect(store.getObject(SKILL_OBJECT_KIND, 'anything')).toBeNull(); + expect(store.allObjects(SKILL_OBJECT_KIND)).toEqual({}); + }); +}); + +// ─── Timeouts ──────────────────────────────────────────────────────────────── + +/** + * A listening socket that accepts connections and never sends a byte. + * + * This is the host `readTimeoutSeconds` exists for: the TCP handshake completes, so + * nothing fails fast, and then no response ever comes. A request against it can + * only end by timing out, which makes the elapsed time a direct measurement of + * the timeout actually applied. + */ +class BlackHole { + private server!: TcpServer; + private readonly accepted: Socket[] = []; + baseUri = ''; + + async listen(): Promise { + this.server = createTcpServer((socket) => { + this.accepted.push(socket); + socket.on('error', () => {}); + }); + await new Promise((resolve) => this.server.listen(0, '127.0.0.1', resolve)); + const address = this.server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + this.baseUri = `http://127.0.0.1:${port}`; + } + + async close(): Promise { + for (const socket of this.accepted) socket.destroy(); + await new Promise((resolve) => this.server.close(() => resolve())); + } +} + +const requesterOf = (store: FDv2SkillStore): FetchRequester => + (store as unknown as { requester: FetchRequester }).requester; + +describe('timeouts', () => { + // `readTimeoutSeconds` is the only network timeout, and every request honours it. + // The bounds asserted here are loose on purpose: the point is that a request + // against an unresponsive host fails in roughly `readTimeoutSeconds` rather than + // in minutes, and that a regression back to a much longer default fails this + // suite quickly instead of hanging it. + let blackHole: BlackHole; + + beforeEach(async () => { + blackHole = new BlackHole(); + await blackHole.listen(); + }); + + afterEach(async () => { + await blackHole.close(); + }); + + it('fails a poll against an unresponsive host within readTimeoutSeconds', async () => { + const requester = new FetchRequester(SDK_KEY, blackHole.baseUri, 0.3); + const started = Date.now(); + await expect(requester.poll(null, null, new AbortController().signal)).rejects.toThrow(/timed out/); + const elapsed = Date.now() - started; + expect(elapsed).toBeGreaterThanOrEqual(200); + expect(elapsed).toBeLessThan(2000); + }); + + it('fails a stream connect against an unresponsive host within readTimeoutSeconds', async () => { + const requester = new FetchRequester(SDK_KEY, blackHole.baseUri, 0.3); + const started = Date.now(); + await expect(requester.stream(null, new AbortController().signal)).rejects.toThrow(RecoverableTransportError); + expect(Date.now() - started).toBeLessThan(2000); + }); + + it('reports the timeout and keeps going', async () => { + const store = new FDv2SkillStore(SDK_KEY, { + baseUri: blackHole.baseUri, + mode: 'poll', + pollIntervalSeconds: 0.05, + initialBackoffSeconds: 0.01, + maxBackoffSeconds: 0.05, + readTimeoutSeconds: 0.3, + }); + openStores.push(store); + store.start(); + expect(await waitUntil(() => store.diagnostics.connectionFailures >= 1)).toBe(true); + expect(store.failed).toBeNull(); + expect(store.diagnostics.lastError).toContain('timed out'); + }); + + it('returns promptly from close while a connect is pending', async () => { + // Before the connect returns there is no body read to interrupt; aborting + // the signal has to reach the pending `fetch` itself, or close waits on a + // host that will never speak. + const store = new FDv2SkillStore(SDK_KEY, { baseUri: blackHole.baseUri, mode: 'stream', readTimeoutSeconds: 60 }); + store.start(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const started = Date.now(); + await store.close(); + expect(Date.now() - started).toBeLessThan(1000); + expect(store.failed).toBeNull(); + }); + + it('defaults the bound per mode', () => { + expect(DEFAULT_POLL_TIMEOUT_SECONDS).toBe(10); + expect(DEFAULT_STREAM_READ_TIMEOUT_SECONDS).toBe(300); + expect(requesterOf(new FDv2SkillStore(SDK_KEY, { mode: 'poll' })).readTimeoutSeconds).toBe( + DEFAULT_POLL_TIMEOUT_SECONDS, + ); + expect(requesterOf(new FDv2SkillStore(SDK_KEY, { mode: 'stream' })).readTimeoutSeconds).toBe( + DEFAULT_STREAM_READ_TIMEOUT_SECONDS, + ); + }); + + it.each(['poll', 'stream'] as const)('lets an explicit readTimeoutSeconds override the %s default', (mode) => { + expect(requesterOf(new FDv2SkillStore(SDK_KEY, { mode, readTimeoutSeconds: 42 })).readTimeoutSeconds).toBe(42); + }); + + it.each([0, -1, Number.POSITIVE_INFINITY, Number.NaN])('rejects a non-positive readTimeoutSeconds (%s)', (value) => { + expect(() => new FDv2SkillStore(SDK_KEY, { readTimeoutSeconds: value })).toThrow(/readTimeoutSeconds/); + }); +}); + +// ─── Endpoints ─────────────────────────────────────────────────────────────── + +describe('endpoints', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + /** Runs one poll and one stream connect through a stubbed `fetch`; returns the URLs requested. */ + async function requestedUrls(requester: FetchRequester): Promise<{ poll: string; stream: string }> { + const urls: string[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + urls.push(String(input)); + return new Response(null, { status: 304 }); + }); + await requester.poll(null, null, new AbortController().signal); + await expect(requester.stream(null, new AbortController().signal)).rejects.toThrow(); + return { poll: urls[0], stream: urls[1] }; + } + + it('polls sdk.launchdarkly.com and streams from stream.launchdarkly.com by default', async () => { + expect(SKILLS_DEFAULT_BASE_URI).toBe('https://sdk.launchdarkly.com'); + expect(SKILLS_DEFAULT_STREAM_URI).toBe('https://stream.launchdarkly.com'); + const requester = requesterOf(new FDv2SkillStore(SDK_KEY)); + expect(requester.baseUri).toBe(SKILLS_DEFAULT_BASE_URI); + expect(requester.streamUri).toBe(SKILLS_DEFAULT_STREAM_URI); + const { poll, stream } = await requestedUrls(requester); + expect(poll).toBe('https://sdk.launchdarkly.com/sdk/poll?kinds=agent-skill'); + expect(stream).toBe('https://stream.launchdarkly.com/sdk/stream?kinds=agent-skill'); + }); + + it('sends both endpoints to a custom baseUri when no streamUri is given', async () => { + const requester = requesterOf(new FDv2SkillStore(SDK_KEY, { baseUri: 'https://relay.example.com/' })); + const { poll, stream } = await requestedUrls(requester); + expect(poll).toBe('https://relay.example.com/sdk/poll?kinds=agent-skill'); + expect(stream).toBe('https://relay.example.com/sdk/stream?kinds=agent-skill'); + }); + + it('lets streamUri differ from baseUri', async () => { + const requester = requesterOf( + new FDv2SkillStore(SDK_KEY, { baseUri: 'https://sdk.example.com', streamUri: 'https://stream.example.com/' }), + ); + const { poll, stream } = await requestedUrls(requester); + expect(poll).toBe('https://sdk.example.com/sdk/poll?kinds=agent-skill'); + expect(stream).toBe('https://stream.example.com/sdk/stream?kinds=agent-skill'); + }); + + it('keeps the default poll host when only streamUri is given', () => { + const requester = requesterOf(new FDv2SkillStore(SDK_KEY, { streamUri: 'https://stream.example.com' })); + expect(requester.baseUri).toBe(SKILLS_DEFAULT_BASE_URI); + expect(requester.streamUri).toBe('https://stream.example.com'); + }); + + it('carries the basis to whichever host the request goes to', async () => { + const requester = new FetchRequester(SDK_KEY, 'https://sdk.example.com', 1, 'https://stream.example.com'); + const urls: string[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + urls.push(String(input)); + return new Response(null, { status: 304 }); + }); + await requester.poll('(p:a:1)', null, new AbortController().signal); + await expect(requester.stream('(p:a:1)', new AbortController().signal)).rejects.toThrow(); + expect(urls[0]).toBe('https://sdk.example.com/sdk/poll?kinds=agent-skill&basis=%28p%3Aa%3A1%29'); + expect(urls[1]).toBe('https://stream.example.com/sdk/stream?kinds=agent-skill&basis=%28p%3Aa%3A1%29'); + }); + + /** + * An oversized poll body, served in `chunk`-sized reads and counting how many + * were pulled. Finite on purpose, at a little past the bound: an unbounded + * reader then fails the read count rather than running the worker out of + * memory, so a regression here reads as an assertion and not as a crash. + */ + const oversizedPollBody = (chunk: number): { body: ReadableStream; reads: () => number } => { + const total = Math.ceil(MAX_RESPONSE_CHARS / chunk) + 8; + let reads = 0; + return { + reads: () => reads, + body: new ReadableStream({ + pull(controller) { + if (reads >= total) { + controller.close(); + return; + } + reads += 1; + controller.enqueue(new TextEncoder().encode('x'.repeat(chunk))); + }, + }), + }; + }; + + it('bounds a poll body rather than buffering whatever the server sends', async () => { + // `response.text()` would materialize the whole body and leave it to be + // measured after, which is no bound at all — the allocation has already + // happened. The read count is what pins that it stops early rather than + // reading to the end and rejecting the result. + const chunk = 1024 * 1024; + const { body, reads } = oversizedPollBody(chunk); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, { status: 200 })); + const requester = new FetchRequester(SDK_KEY, 'https://sdk.example.com', 1); + await expect(requester.poll(null, null, new AbortController().signal)).rejects.toBeInstanceOf(FatalTransportError); + expect(reads()).toBeLessThanOrEqual(MAX_RESPONSE_CHARS / chunk + 2); + }); + + it('says nothing was applied when a poll body crosses the bound', async () => { + const { body } = oversizedPollBody(4 * 1024 * 1024); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, { status: 200 })); + const requester = new FetchRequester(SDK_KEY, 'https://sdk.example.com', 1); + await expect(requester.poll(null, null, new AbortController().signal)).rejects.toThrow( + /exceeded the \d+ character transport bound.*nothing from it was applied/, + ); + }); + + it('reassembles a poll body that arrives across several reads', async () => { + // The bounded read decodes incrementally, so a multi-byte character split + // across two reads must not be mangled into replacement characters — that + // would corrupt content the hash is checked against. + const payload = JSON.stringify({ events: [{ event: 'put-object', data: { note: 'café — naïve' } }] }); + const bytes = new TextEncoder().encode(payload); + const split = bytes.indexOf(0xc3) + 1; // mid-sequence, inside 'é' + const body = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice(0, split)); + controller.enqueue(bytes.slice(split)); + controller.close(); + }, + }); + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(body, { status: 200 })); + const requester = new FetchRequester(SDK_KEY, 'https://sdk.example.com', 1); + const result = await requester.poll(null, null, new AbortController().signal); + expect(result.events).toEqual([['put-object', { note: 'café — naïve' }]]); + }); +}); + +// ─── Layering, and the absence of telemetry ────────────────────────────────── + +// ─── Transport contract assertions ─────────────────────────────────────────── + +describe('transport contract', () => { + const source = readFileSync(new URL('../skills-fdv2.ts', import.meta.url), 'utf8'); + + it('holds the wire kind as its own declaration, not an alias of the seam kind', () => { + // One is a wire value LaunchDarkly owns, the other an SDK seam. They are + // equal today; a change to either must be a deliberate change to that one. + expect(source).toMatch(/export const FDV2_OBJECT_KIND = 'skill';/); + expect(source).not.toMatch(/FDV2_OBJECT_KIND\s*=\s*SKILL_OBJECT_KIND/); + expect(FDV2_OBJECT_KIND).toBe(SKILL_OBJECT_KIND); + }); + + it('holds the payload kind apart from the object kind, by source text', () => { + // Two different things, and neither derived from the other: the store asks + // for a payload of kind `agent-skill` and reads objects of kind `skill` out + // of it. Held apart so a rename of either cannot silently move the other, + // and asserted against the source for the same reason the seam-kind test + // above is — an alias would satisfy the value assertions on its own. + expect(source).toMatch(/export const FDV2_PAYLOAD_KIND = 'agent-skill';/); + expect(source).not.toMatch(/FDV2_PAYLOAD_KIND\s*=\s*FDV2_OBJECT_KIND/); + expect(source).not.toMatch(/FDV2_OBJECT_KIND\s*=\s*FDV2_PAYLOAD_KIND/); + expect(FDV2_PAYLOAD_KIND).toBe('agent-skill'); + expect(FDV2_PAYLOAD_KIND).not.toBe(FDV2_OBJECT_KIND); + }); + + it('declares no payloadUnavailable field and no NoSkillPayloadError, by source text', () => { + // `StoreDiagnostics` is public API from the moment it ships, and a + // type-level removal is invisible at runtime — so the absence is asserted + // against the source, the way the kind constants above are. A field still + // declared but never incremented would pass a test that only looked at a + // snapshot. + expect(source).not.toMatch(/payloadUnavailable/); + // There is no expected-recoverable class at all, not merely an unexported + // one: a status handled as neither recoverable nor fatal is a retry + // loop off the backoff schedule. + expect(source).not.toMatch(/NoSkillPayloadError/); + // And no idle warning, no retry parked at the cap instead of on the + // backoff schedule. + expect(source).not.toMatch(/delivery is idle/i); + expect(source).not.toMatch(/warnedNoSkillPayload/); + }); + + it('declares no maxConsecutiveFailures option, by source text (§3.25)', () => { + // The option would freeze a give-up contract into the public API, so its + // absence is asserted by name, the same way as payloadUnavailable's. + expect(source).not.toMatch(/maxConsecutiveFailures/); + expect(source).not.toMatch(/consecutive failures/); + }); + + it('does not read a maxConsecutiveFailures passed anyway', async () => { + // The runtime half: an untyped caller passing the old option gets no bound. + const store = scriptedStreamStore(new ScriptedRequester(), { maxConsecutiveFailures: 1 }); + store.start(); + expect(await waitUntil(() => store.diagnostics.connectionFailures >= 5)).toBe(true); + expect(store.failed).toBeNull(); + }); + + it('carries no payloadUnavailable key on a real diagnostics snapshot', () => { + // The runtime half of the assertion above: the source check would pass a + // field added dynamically, and this one would pass a field declared and + // never written. Both, because neither alone is the whole fact. + const store = pollStore(); + expect(Object.keys(store.diagnostics)).not.toContain('payloadUnavailable'); + expect('payloadUnavailable' in store.diagnostics).toBe(false); + }); + + it('the 401 message names the SDK key', () => { + expect(classifyStatus(401).message).toMatch(/SDK key/); + }); + + it('a goodbye on a store holding committed content keeps it', () => { + const held = new SkillObjectSet(); + const reader = new ProtocolReader(held); + drive(reader, fullPayload([['put-object', putSkill()]])); + const outcome = reader.handle('goodbye', { reason: 'recycle', silent: true, catastrophe: false }); + expect(outcome.disconnect).toBeTruthy(); + expect(held.get('pdf-extraction', null)).not.toBeNull(); + // An in-flight transfer is abandoned too, without touching what was committed. + drive(reader, events(['server-intent', serverIntent('xfer-full')], ['put-object', putSkill('other')])); + reader.handle('goodbye', { reason: 'recycle', silent: true }); + expect(held.size).toBe(1); + }); + + it('giving up logs the error and keeps serving last known good, in one test', async () => { + const requester = new ScriptedRequester([ + asPairs(fullPayload([['put-object', putSkill()]])), + new FatalTransportError('HTTP 401'), + ]); + const store = scriptedStreamStore(requester); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => store.failed !== null, 5000)).toBe(true); + expect(consoleErrors()).toMatch(/will not retry/); + expect(consoleErrors()).toMatch(/HTTP 401/); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + expect(store.allObjects(SKILL_OBJECT_KIND)).toHaveProperty('pdf-extraction'); + }); + + it('holds a tampered object while the accessor reports integrity_failure', async () => { + // Verification is the accessor's job, not the transport's: the store keeps + // what arrived, and the reported outcome is what tells a caller to fail closed. + endpoint.queuePoll(fullPayload([['put-object', putSkill('tampered', { contentHash: hash('something else') })]])); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + _setStore(store); + expect(store.getObject(SKILL_OBJECT_KIND, 'tampered')).not.toBeNull(); + const outcome = await getSkillResult('tampered'); + expect(outcome.reason).toBe('integrity_failure'); + expect(outcome.skill).toBeNull(); + expect(store.getObject(SKILL_OBJECT_KIND, 'tampered')).not.toBeNull(); + }); + + it.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + ])('rejects a non-positive or non-finite pollIntervalSeconds (%s)', (value) => { + // `NaN` is the case a `<= 0` guard misses. + expect(() => new FDv2SkillStore(SDK_KEY, { mode: 'poll', pollIntervalSeconds: value })).toThrow( + /pollIntervalSeconds/, + ); + }); + + describe.each(['initialBackoffSeconds', 'maxBackoffSeconds'] as const)('%s', (option) => { + // With no failure bound, the backoff options are the only limit on the + // retry loop: a zero or negative delay against a failing server is a + // reconnect as fast as the network allows, for the life of the process. + it.each([ + 0, + -1, + -5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ])('rejects a non-positive or non-finite value (%s)', (value) => { + // The other option is set out of the way, so only the one under test can + // be what the constructor refuses. + const other = option === 'initialBackoffSeconds' ? { maxBackoffSeconds: 30 } : { initialBackoffSeconds: 0.001 }; + expect(() => new FDv2SkillStore(SDK_KEY, { ...other, [option]: value })).toThrow(new RegExp(option)); + }); + }); + + it('rejects an initialBackoffSeconds greater than maxBackoffSeconds', () => { + expect(() => new FDv2SkillStore(SDK_KEY, { initialBackoffSeconds: 5, maxBackoffSeconds: 0.02 })).toThrow( + /initialBackoffSeconds.*must not exceed maxBackoffSeconds/, + ); + // Either one alone can produce the inversion against the other's default. + expect(() => new FDv2SkillStore(SDK_KEY, { initialBackoffSeconds: 60 })).toThrow(/must not exceed/); + expect(() => new FDv2SkillStore(SDK_KEY, { maxBackoffSeconds: 0.5 })).toThrow(/must not exceed/); + }); + + it('accepts an initialBackoffSeconds equal to maxBackoffSeconds, and the defaults', () => { + expect(() => new FDv2SkillStore(SDK_KEY, { initialBackoffSeconds: 0.25, maxBackoffSeconds: 0.25 })).not.toThrow(); + expect(() => new FDv2SkillStore(SDK_KEY)).not.toThrow(); + }); + + it('rejects a non-string credential', () => { + expect(() => new FDv2SkillStore(42 as never)).toThrow(/server-side SDK key/); + expect(() => new FDv2SkillStore(undefined as never)).toThrow(/server-side SDK key/); + }); + + it('refusal messages for mobile and client-side credentials say skills are server-side', () => { + expect(() => new FDv2SkillStore('mob-00000000-0000-4000-8000-000000000000')).toThrow(/server-side/); + expect(() => new FDv2SkillStore('0123456789abcdef01234567')).toThrow(/server-side/); + }); + + it.each([ + Number.NaN, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY, + -1, + ])('waitForSkills rejects a non-finite or negative timeoutSeconds (%s)', async (value) => { + const store = pollStore(); + await expect(store.waitForSkills({ timeoutSeconds: value })).rejects.toThrow(/timeoutSeconds/); + }); + + it.each([ + ['a bare number', 10_000, 'number 10000'], + ['null', null, 'null'], + ['a string', '10', '"10"'], + ['an array', [], '[]'], + ])('waitForSkills rejects %s with a TypeError naming the options form', async (_label, value, shown) => { + // The timeout used to be a positional millisecond count. Read as seconds, + // `waitForSkills(10_000)` would wait almost three hours, so it is refused. + // `String([])` is empty and `String('10')` hides that it was a string. + const store = pollStore(); + const wait = store.waitForSkills(value as never); + await expect(wait).rejects.toBeInstanceOf(TypeError); + await expect(wait).rejects.toThrow(/waitForSkills\(\{ timeoutSeconds: 10 \}\)/); + await expect(wait).rejects.toThrow(`got ${shown}`); + }); + + it('waitForSkills defaults to ten seconds, and accepts a timeout of zero', async () => { + const store = pollStore(); + const timers = vi.spyOn(globalThis, 'setTimeout'); + const waiting = store.waitForSkills(); + expect(timers.mock.calls.map((call) => call[1])).toContain(10_000); + // A wait already pending resolves `false` when the store closes. + await store.close(); + expect(await waiting).toBe(false); + expect(await pollStore().waitForSkills({ timeoutSeconds: 0 })).toBe(false); + }); + + it.each([ + [1.005, 0, 1005], + [0.29, 0, 290], + [30, 0, 30_000], + [0, 0, 0], + [0.0001, 1, 1], + [0.0001, 0, 0], + // Above 2^31−1 ms, `setTimeout` fires after 1ms. + [3e6, 1, 2 ** 31 - 1], + [3e6, 0, 2 ** 31 - 1], + ])('converts %s seconds to whole milliseconds (minimum %s): %s', (seconds, minimum, ms) => { + // Rounded rather than truncated: `1.005 * 1000` is `1004.9999999999999`. + // The floor of 1 is for options that must be positive, so a sub-millisecond + // value cannot become a zero delay. + expect(secondsToMs(seconds, minimum)).toBe(ms); + }); + + it('converts a time option to milliseconds only at the timer it sets', async () => { + // `pollIntervalSeconds: 1.005` must sleep 1005ms between polls. Truncating + // `1.005 * 1000` would sleep 1004. + const timers = vi.spyOn(globalThis, 'setTimeout'); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 1.005, + requester: new ScriptedRequester([asPairs(fullPayload([['put-object', putSkill()]]))]), + }); + openStores.push(store); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => timers.mock.calls.some((call) => call[1] === 1005))).toBe(true); + }); + + it('caps a poll interval past the setTimeout limit instead of spinning', async () => { + // `pollIntervalSeconds: 3e6` is about 34 days. Uncapped, Node's timer + // overflows and fires after 1ms, so poll mode spins. + const timers = vi.spyOn(globalThis, 'setTimeout'); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalSeconds: 3e6, + requester: new ScriptedRequester([asPairs(fullPayload([['put-object', putSkill()]]))]), + }); + openStores.push(store); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => timers.mock.calls.some((call) => call[1] === 2 ** 31 - 1))).toBe(true); + }); + + it('warns once when a retired millisecond option is passed, and does not read it', async () => { + const timers = vi.spyOn(globalThis, 'setTimeout'); + const store = new FDv2SkillStore(SDK_KEY, { + mode: 'poll', + pollIntervalMs: 5000, + readTimeoutMs: 1, + initialBackoffMs: 1, + maxBackoffMs: 300_000, + requester: new ScriptedRequester([asPairs(fullPayload([['put-object', putSkill()]]))]), + } as never); + openStores.push(store); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = logged(warnSpy); + expect(message).toContain('pollIntervalMs is no longer read; use pollIntervalSeconds (seconds)'); + expect(message).toContain('readTimeoutMs is no longer read; use readTimeoutSeconds (seconds)'); + expect(message).toContain('initialBackoffMs is no longer read; use initialBackoffSeconds (seconds)'); + expect(message).toContain('maxBackoffMs is no longer read; use maxBackoffSeconds (seconds)'); + store.start(); + // The wait is 1s, so a 5000ms timer can only be the old poll interval being read. + expect(await store.waitForSkills({ timeoutSeconds: 1 })).toBe(true); + expect(await waitUntil(() => timers.mock.calls.some((call) => call[1] === 30_000))).toBe(true); + expect(timers.mock.calls.some((call) => call[1] === 5000)).toBe(false); + }); + + it('an idle stream carrying heartbeats stays connected past what the read timeout alone would allow', async () => { + // The read deadline bounds the gap between reads, not the connection's + // life. Heartbeats well inside `readTimeoutSeconds` keep one connection open for + // several multiples of it, with no reconnect. + endpoint.holdStreamOpen = true; + endpoint.heartbeatMs = 20; + endpoint.queueStream(fullPayload([['put-object', putSkill()]])); + const store = streamStore({ readTimeoutSeconds: 0.1 }); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 450)); + expect(endpoint.requests).toHaveLength(1); + expect(store.failed).toBeNull(); + expect(store.diagnostics.connectionFailures).toBe(0); + expect(store.diagnostics.lastError).toBeNull(); + }); + + it('a stream interrupted by close does not count as a success', async () => { + // `streamOnce` returning because the signal aborted is the store closing, + // not the server answering — so the row of failures must not be reset by it. + let release: (() => void) | null = null; + const parked: Requester = { + poll() { + throw new Error('not a polling double'); + }, + async stream(_basis, signal) { + return (async function* () { + yield ['heart-beat', {}] as [string, unknown]; + await new Promise((resolve) => { + release = resolve; + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + // One more event after the abort, so the loop observes the signal. + yield ['heart-beat', {}] as [string, unknown]; + })(); + }, + }; + const requester = new ScriptedRequester([new RecoverableTransportError('x'), parked as never]); + const wrapped: Requester = { + poll: (b, e, s) => requester.poll(b, e, s), + stream: async (b, s) => { + if (requester.calls.length === 0) return requester.stream(b, s); + requester.calls.push([b, null]); + return parked.stream(b, s); + }, + }; + const store = scriptedStreamStore(wrapped); + store.start(); + expect(await waitUntil(() => release !== null, 5000)).toBe(true); + expect(store.diagnostics.connectionFailures).toBe(1); + await store.close(); + expect(store.diagnostics.connectionFailures).toBe(1); + }); + + // Every request carries the SDK key in `Authorization`, so the base URI is + // `https://` only. Plain `http://` is allowed to a loopback host and nowhere + // else: that is what this suite's own endpoints listen on, and it never + // leaves the machine. + describe('base URI scheme', () => { + it('refuses a plain http:// baseUri, naming https://', () => { + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: 'http://example.com' })).toThrow(/cleartext/); + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: 'http://sdk.launchdarkly.com' })).toThrow(/https:\/\//); + }); + + it('refuses a plain http:// baseUri even with a requester injected', () => { + // The check is on the store, not on the socket it happens to open. + const requester = new ScriptedRequester([]); + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: 'http://relay.internal:8030', requester })).toThrow( + /cleartext/, + ); + }); + + it.each([ + 'http://localhost:8030', + 'http://127.0.0.1:8030', + 'http://[::1]:8030', + 'http://LOCALHOST/', + ])('allows plain http:// to a loopback host (%s)', (uri) => { + expect(requesterOf(new FDv2SkillStore(SDK_KEY, { baseUri: uri })).baseUri).toBe(uri.replace(/\/+$/, '')); + expect(requesterOf(new FDv2SkillStore(SDK_KEY, { streamUri: uri })).streamUri).toBe(uri.replace(/\/+$/, '')); + }); + + it('a private address is not loopback', () => { + // Only the machine itself is exempt; the LAN is not. + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: 'http://10.0.0.5:8030' })).toThrow(/cleartext/); + }); + + it.each([ + '', + ' ', + 'sdk.launchdarkly.com', + 'ftp://sdk.launchdarkly.com', + 'https://', + ])('refuses anything but an https:// URL with a host (%j)', (uri) => { + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: uri })).toThrow(/https:\/\//); + expect(() => new FDv2SkillStore(SDK_KEY, { streamUri: uri })).toThrow(/https:\/\//); + }); + + it('refuses a non-string URI', () => { + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: 42 as never })).toThrow(/https:\/\/.*none was given/); + }); + + it('accepts https://, and the defaults', () => { + expect(requesterOf(new FDv2SkillStore(SDK_KEY, { baseUri: 'https://sdk.example.com/' })).baseUri).toBe( + 'https://sdk.example.com', + ); + expect(requesterOf(new FDv2SkillStore(SDK_KEY)).baseUri).toBe(SKILLS_DEFAULT_BASE_URI); + }); + + it('refuses a plain http:// streamUri by name', () => { + // The streaming host is checked too, and the message names it. + expect( + () => + new FDv2SkillStore(SDK_KEY, { baseUri: 'https://sdk.example.com', streamUri: 'http://stream.example.com' }), + ).toThrow(/streamUri.*cleartext/); + }); + + it('the refusal names the defaults', () => { + expect(() => new FDv2SkillStore(SDK_KEY, { baseUri: 'http://example.com' })).toThrow( + /https:\/\/sdk\.launchdarkly\.com.*https:\/\/stream\.launchdarkly\.com/, + ); + }); + }); + + // `fetch`'s default `redirect: 'follow'` copies every request header onto + // the redirected request, `Authorization` included. Both requests are sent + // with `redirect: 'manual'` instead, so a 3xx is a fatal, non-retried failure + // and the SDK key never reaches the host `Location` names. + describe('redirects are refused', () => { + it.each([301, 302, 307, 308])('a poll redirect (%i) is fatal and not followed', async (status) => { + const second = await secondEndpoint(); + endpoint.queuePoll([], { status, location: `${second.baseUri}/sdk/poll` }); + const requester = new FetchRequester(SDK_KEY, endpoint.baseUri, 5); + const failure = await requester.poll(null, null, new AbortController().signal).then( + () => null, + (cause: unknown) => cause, + ); + expect(failure).toBeInstanceOf(FatalTransportError); + expect((failure as Error).message).toContain(String(status)); + expect((failure as Error).message).toMatch(/redirect/i); + expect((failure as Error).message).toContain('not followed'); + expect(endpoint.requests).toHaveLength(1); + expect(second.requests).toEqual([]); + }); + + it('a stream redirect is fatal and not followed', async () => { + const second = await secondEndpoint(); + endpoint.redirectStreamTo = `${second.baseUri}/sdk/stream`; + const requester = new FetchRequester(SDK_KEY, endpoint.baseUri, 5); + await expect(requester.stream(null, new AbortController().signal)).rejects.toThrow(FatalTransportError); + await expect(requester.stream(null, new AbortController().signal)).rejects.toThrow(/307.*redirect/); + expect(second.requests).toEqual([]); + }); + + it('a same-host redirect is refused too', async () => { + // The endpoints do not redirect, so there is nothing legitimate to follow. + endpoint.queuePoll([], { status: 302, location: `${endpoint.baseUri}/sdk/poll` }); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const requester = new FetchRequester(SDK_KEY, endpoint.baseUri, 5); + await expect(requester.poll(null, null, new AbortController().signal)).rejects.toThrow(FatalTransportError); + expect(endpoint.requests).toHaveLength(1); + }); + + it('routes a 3xx through classifyStatus as fatal, and 304 not at all', () => { + for (const status of [300, 301, 302, 303, 307, 308]) { + expect(classifyStatus(status)).toBeInstanceOf(FatalTransportError); + expect(classifyStatus(status).message).toMatch(/never forwarded/); + } + expect(classifyStatus(304)).not.toBeInstanceOf(FatalTransportError); + }); + + it('the SDK key never reaches the second host', async () => { + // End to end through the store: the redirect stops delivery for good, + // with no retry spent on it, and the second host sees no request at all — + // so no `Authorization` header, since that is what following would have + // forwarded. + const second = await secondEndpoint(); + endpoint.queuePoll([], { status: 301, location: `${second.baseUri}/sdk/poll` }); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(false); + expect(store.failed).toContain('301'); + expect(store.failed).toContain('never forwarded'); + expect(consoleErrors()).toContain('redirect'); + expect(store.diagnostics.connectionFailures).toBe(0); + expect(endpoint.requests).toHaveLength(1); + expect(endpoint.requests[0]?.authorization).toBe(SDK_KEY); + expect(second.requests.map((r) => r.authorization)).toEqual([]); + }); + + it('a redirect in stream mode stops delivery', async () => { + const second = await secondEndpoint(); + endpoint.redirectStreamTo = `${second.baseUri}/sdk/stream`; + const store = streamStore(); + store.start(); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('307'); + expect(endpoint.requests).toHaveLength(1); + expect(second.requests).toEqual([]); + }); + + it('a redirect after a committed payload keeps serving last known good', async () => { + const second = await secondEndpoint(); + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + endpoint.queuePoll([], { status: 302, location: `${second.baseUri}/sdk/poll` }); + const store = pollStore(); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => store.failed !== null)).toBe(true); + expect(store.failed).toContain('302'); + expect(store.getObject(SKILL_OBJECT_KIND, 'pdf-extraction')).not.toBeNull(); + expect(second.requests).toEqual([]); + }); + + it('a redirect with no Location is still fatal', async () => { + endpoint.queuePoll([], { status: 302 }); + const requester = new FetchRequester(SDK_KEY, endpoint.baseUri, 5); + await expect(requester.poll(null, null, new AbortController().signal)).rejects.toThrow(FatalTransportError); + }); + + it('a 304 is not a redirect', async () => { + // The refusal must leave the poll's not-modified path exactly as it was. + endpoint.queuePoll([], { status: 304 }); + const requester = new FetchRequester(SDK_KEY, endpoint.baseUri, 5); + const result = await requester.poll(null, 'etag-1', new AbortController().signal); + expect(result.notModified).toBe(true); + expect(result.etag).toBe('etag-1'); + expect(result.events).toEqual([]); + }); + + it('both fetches are sent with redirect: manual', () => { + // Pinned at the source, since the endpoint doubles cannot tell 'manual' + // from a runtime that happened not to follow. + expect(source.match(/^\s+redirect: 'manual',$/gm)).toHaveLength(2); + expect(source).not.toMatch(/redirect: 'follow'/); + }); + }); +}); + +describe('listeners', () => { + it('logs an async listener whose promise rejects rather than leaving it unhandled', async () => { + endpoint.queuePoll(fullPayload([['put-object', putSkill()]])); + const store = pollStore(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + store.addListener(SKILL_OBJECT_KIND, async () => { + throw new Error('async listener exploded'); + }); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => /async listener exploded/.test(consoleErrors()), 5000)).toBe(true); + expect(consoleErrors()).toContain('delivery continues'); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(unhandled).toEqual([]); + expect(store.failed).toBeNull(); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); +}); + +describe('layering', () => { + /** `skills-fdv2.ts`'s own source text — the only way to assert a leaf. */ + const source = readFileSync(new URL('../skills-fdv2.ts', import.meta.url), 'utf8'); + + /** Every `from '...'` specifier in a source file, in order. */ + function importsOf(text: string): string[] { + return [...text.matchAll(/^\s*(?:import|export)\b[^;]*?from\s+'([^']+)'/gm)].map(([, specifier]) => specifier); + } + + it('adds no dependency beyond the standard library', () => { + // The package's sole runtime dependency is the OpenTelemetry API, and the + // transport is not allowed to add a second one. A `package.json` assertion + // would not catch it — a transitive dependency of another package resolves + // perfectly well from here. + const external = importsOf(source).filter((specifier) => !specifier.startsWith('.')); + expect(external.filter((specifier) => !specifier.startsWith('node:'))).toEqual([]); + }); + + it('imports the shared internals for the seam kind and nothing else from the feature', () => { + // The transport sits *below* the `SkillStore` interface: it produces raw + // wire objects and knows nothing about verification, the `Skill` type, or + // materialization. The seam kind is the one thing it legitimately needs from + // above, so that import is spelled out and the rest are named as forbidden. + const relative = [...new Set(importsOf(source).filter((specifier) => specifier.startsWith('.')))]; + // `types.js` is the package's value-type module, not a layer of the feature: + // the wire objects it produces have to be typed somehow. `skills-core.js` is + // the only feature module, and `skills.js` / `skills-fs.js` / + // `skills-watch.js` are absent by construction rather than by coincidence. + expect(relative.sort()).toEqual(['./skills-core.js', './types.js']); + + // And only the seam kind comes out of the shared internals — not the + // verification helpers, not the store state. + const fromCore = source.match(/import\s*\{([^}]*)\}\s*from\s*'\.\/skills-core\.js'/); + expect(fromCore).not.toBeNull(); + expect((fromCore as RegExpMatchArray)[1].split(',').map((name) => name.trim())).toEqual(['SKILL_OBJECT_KIND']); + }); + + it('is imported by nothing in the feature, so the layering cannot invert', () => { + // The package index re-exports it, which is the publication point rather + // than a layer — but no *module* of the feature may reach for it, or the + // accessors would start depending on one particular transport. + for (const module of ['skills.ts', 'skills-core.ts', 'skills-fs.ts', 'skills-watch.ts']) { + const text = readFileSync(new URL(`../${module}`, import.meta.url), 'utf8'); + expect(importsOf(text), module).not.toContain('./skills-fdv2.js'); + } + }); +}); + +describe('telemetry', () => { + it('records no signal of its own across a full delivery cycle', async () => { + // The three signals in the allowlist belong to verification and to + // materialization. The transport holds what arrived and counts what it + // ignored; it does not judge content, so it has nothing to report — in + // particular not for the hashless object below, which is verification's to + // withhold and not the transport's to flag. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + endpoint.queuePoll( + fullPayload([ + ['put-object', putSkill('pdf-extraction')], + ['put-object', putSkill('hashless', { omitHash: true })], + ['put-object', putSkill('tampered', { contentHash: hash('something else') })], + ['put-object', putFlag()], + ]), + ); + endpoint.queuePoll( + events( + ['server-intent', serverIntent('xfer-changes')], + ['delete-object', deleteSkill('pdf-extraction')], + ['payload-transferred', transferred('basis-2')], + ), + ); + + const store = pollStore({ pollIntervalSeconds: 0.02 }); + store.start(); + expect(await store.waitForSkills({ timeoutSeconds: 5 })).toBe(true); + expect(await waitUntil(() => store.diagnostics.objectsRevoked > 0, 10_000)).toBe(true); + // Serving is part of the cycle too, and it is the surface a naive + // implementation would verify on. + store.allObjects(SKILL_OBJECT_KIND); + store.getObject(SKILL_OBJECT_KIND, 'tampered'); + await store.close(); + + expect(emitter.records).toEqual([]); + }); +}); diff --git a/packages/client/src/__tests__/skills-fs-byte-integrity.test.ts b/packages/client/src/__tests__/skills-fs-byte-integrity.test.ts new file mode 100644 index 00000000..9ae23b62 --- /dev/null +++ b/packages/client/src/__tests__/skills-fs-byte-integrity.test.ts @@ -0,0 +1,466 @@ +/** + * Which bytes `writeSkills` reads, and which bytes it writes. + * + * Two guarantees that the materialization suite cannot observe, because both are + * about what happens *inside* a single reconcile rather than about its report: + * + * - the adoption comparison read is **bounded** at the resolved content's length + * plus one byte, and + * - the bytes that were hashed are the bytes that get **written** — the + * content is snapshotted before hashing, so a caller mutating `skill.content` + * during the awaits in between cannot substitute bytes nothing verified. + * + * Both need `vi.mock('node:fs/promises')`, which is file-wide, so they live here + * rather than in `skills-fs.test.ts`: the first has to count the bytes a read + * actually consumed, and the second has to fire a mutation from inside the window + * between the hash and the write. This is the same reason + * `skills-fs-root-swap.test.ts` is a file of its own. + * + * Nothing here is platform-gated: both properties hold on every platform, and + * both tests run everywhere. + */ + +import { createHash } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── The read-counting / mutation hook ─────────────────────────────────────── +// +// `open` is wrapped so the FileHandle it returns reports how much was read +// through it, and `mkdir` is wrapped so a test can act at a chosen point in the +// write path. Everything else `node:fs/promises` exports is the real thing. +// +// `reads` is keyed on the *basename*, because on Linux the implementation opens +// its targets through `/proc/self/fd//` rather than through a path +// anyone would recognize (see `SUPPORTS_PROC_FD`), so a key built from the full +// path would simply never match there and the assertions would pass vacuously. + +const hook = vi.hoisted(() => ({ + /** Bytes consumed per opened basename, however the read was issued. */ + reads: new Map(), + /** Which basenames were read with an unbounded whole-file `readFile`. */ + wholeFileReads: new Set(), + /** Fires on `mkdir` of this basename, or `null` when disarmed. */ + onMkdir: null as string | null, + fire: null as (() => void) | null, + fired: false, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + + const count = (name: string, bytes: number): void => { + hook.reads.set(name, (hook.reads.get(name) ?? 0) + bytes); + }; + + return { + ...actual, + async mkdir(...args: unknown[]) { + if (hook.onMkdir !== null && typeof args[0] === 'string' && path.basename(args[0]) === hook.onMkdir) { + hook.fired = true; + hook.fire?.(); + } + return (actual.mkdir as (...a: unknown[]) => Promise)(...args); + }, + async open(...args: unknown[]) { + const handle = await (actual.open as (...a: unknown[]) => Promise)( + ...args, + ); + if (typeof args[0] !== 'string') return handle; + const name = path.basename(args[0]); + // A Proxy rather than a rebuilt object: a FileHandle carries internals the + // implementation uses (`fd`, `stat`, `close`), and only the two read paths + // need observing. + return new Proxy(handle, { + get(target, property, receiver) { + if (property === 'readFile') { + return async (...a: unknown[]) => { + const data = await (target.readFile as (...x: unknown[]) => Promise)(...a); + hook.wholeFileReads.add(name); + count(name, typeof data === 'string' ? Buffer.byteLength(data) : data.byteLength); + return data; + }; + } + if (property === 'read') { + return async (...a: unknown[]) => { + const result = await (target.read as (...x: unknown[]) => Promise<{ bytesRead: number }>)(...a); + count(name, result.bytesRead); + return result; + }; + } + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }, + }; +}); + +const { mkdir, mkdtemp, readFile, realpath, rm, writeFile } = await import('node:fs/promises'); +const { _clearState } = await import('../skills.js'); +const { writeSkills } = await import('../skills-fs.js'); +const { createSkill } = await import('../types.js'); +const { fsOps } = await import('../safe-fs.js'); + +const SKILL_MD = 'SKILL.md'; +const SKILL_BODY = '---\nname: Test Skill\n---\nDo the thing.\n'; +const NEVER_FIRED = 'the hook never fired; the test proves nothing'; + +/** + * Convenience for building fixtures whose `contentHash` is correct. + * + * Deliberately the same expression the implementation hashes with, which is + * what makes it useless as an oracle: a change to the hashing rule moves every + * fixture built here along with it, and nothing in this file would fail. The + * rule is pinned independently, against the digests LaunchDarkly's delivery + * service computes, in the `contentHash contract` suite in `skills.test.ts` for the + * accessor and write paths, and below for the adoption comparison, which + * hashes through an expression of its own. + */ +function hash(bytes: Uint8Array | string): string { + return createHash('sha256') + .update(typeof bytes === 'string' ? Buffer.from(bytes, 'utf-8') : bytes) + .digest('hex'); +} + +function disarm(): void { + hook.reads.clear(); + hook.wholeFileReads.clear(); + Object.assign(hook, { onMkdir: null, fire: null, fired: false }); +} + +let scratch: string; +let root: string; + +beforeEach(async () => { + _clearState(); + disarm(); + scratch = await realpath(await mkdtemp(path.join(tmpdir(), 'ld-ai-skills-bytes-'))); + root = path.join(scratch, 'skills'); + await mkdir(root); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + disarm(); + _clearState(); + await rm(scratch, { recursive: true, force: true }); +}); + +// ─── The bounded comparison read ───────────────────────────────────────────── + +/** + * Adoption reads the file at `//SKILL.md` and compares its + * hash against the resolved content's. That read deliberately reaches files the + * manifest does **not** vouch for — that is the whole crash-recovery self-heal — + * so the file it opens may be one an attacker with write access to the root + * planted, at whatever size they chose. Unbounded, it pulls that file into memory + * before the comparison ever runs. + * + * The bound is `len(content) + 1`, and the `+ 1` is load-bearing rather than + * slack: a file that is the content *plus* trailing bytes would, under a bound of + * exactly `len(content)`, read back as exactly the content, hash equal, and be + * adopted despite not being current. The extra byte is what proves inequality. + */ +describe('the adoption comparison read is bounded', () => { + /** The post-crash state adoption exists for: a managed path, no manifest. */ + async function placeOrphaned(key: string, content: string | Uint8Array): Promise { + const target = path.join(root, key, SKILL_MD); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + return target; + } + + function skill(key: string, content = SKILL_BODY) { + return createSkill({ key, version: 1, content: new TextEncoder().encode(content), contentHash: hash(content) }); + } + + it('reads at most one byte past the resolved content and refuses an over-large file', async () => { + // A megabyte where the resolved content is a few dozen bytes. The size is + // beside the point — what is asserted is that the read stopped at the bound, + // which is the property that holds for a multi-gigabyte file too. + const planted = Buffer.alloc(1024 * 1024, 0x41); + const target = await placeOrphaned('a', planted); + const encoded = new TextEncoder().encode(SKILL_BODY); + + const report = await writeSkills([skill('a')], root); + + // Refused — the bytes are not the resolved content, and no manifest entry + // vouches for the path. + expect(report.ok).toBe(false); + const action = report.actions.find((a) => a.key === 'a'); + expect(action?.action).toBe('error'); + expect(action?.error).toContain('does not record it as managed'); + // Refused *after* a successful comparison, not because the read blew up: + // "we could not look" and "we looked and the bytes are foreign" are + // deliberately different refusals, and only the second proves the bound was + // what stopped the read. + expect(action?.error).not.toContain('could not be read'); + + // The bound itself. `+ 1` and no more, and never the whole file. + expect(hook.reads.get(SKILL_MD)).toBe(encoded.byteLength + 1); + expect(hook.wholeFileReads.has(SKILL_MD)).toBe(false); + // And the planted file is untouched. + expect((await readFile(target)).byteLength).toBe(planted.byteLength); + }); + + it('refuses a file that is the resolved content plus trailing bytes', async () => { + // The case a bound of exactly `len(content)` would adopt: the first + // `len(content)` bytes *are* the resolved content, so a read truncated there + // hashes equal. Only the extra byte distinguishes them. + const trailing = `${SKILL_BODY}extra`; + const target = await placeOrphaned('a', trailing); + const renames: string[] = []; + const realRename = fsOps.rename.bind(fsOps); + vi.spyOn(fsOps, 'rename').mockImplementation(async (src: string, dst: string) => { + if (dst.endsWith(SKILL_MD)) renames.push(dst); + return realRename(src, dst); + }); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + const action = report.actions.find((a) => a.key === 'a'); + expect(action?.action).toBe('error'); + expect(action?.error).toContain('does not record it as managed'); + // Not adopted: nothing was claimed into the manifest, and the file is as it + // was found. + expect(await readFile(target, 'utf-8')).toBe(trailing); + expect(renames).toEqual([]); + expect(JSON.parse(await readFile(path.join(root, '.launchdarkly-skills.json'), 'utf-8')).entries).toEqual({}); + }); + + it('still adopts an exact match, so the bound did not break the self-heal', async () => { + // The positive control for the two above: a bound that refused everything + // would satisfy both of them. Exactly `len(content)` bytes of the resolved + // content is the one file adoption is *for*. + await placeOrphaned('a', SKILL_BODY); + const encoded = new TextEncoder().encode(SKILL_BODY); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + expect(report.actions.find((a) => a.key === 'a')?.action).toBe('skipped_current'); + // The read stops at EOF rather than at the bound, so it is one byte short of + // what the over-large case consumed — which is what "anything longer cannot + // match" means in practice. + expect(hook.reads.get(SKILL_MD)).toBe(encoded.byteLength); + }); + + it('reads the manifest through a bounded read, not to EOF', async () => { + // Its length is not predictable, but it lives in a directory the SDK does not + // own exclusively, so the read is capped at `MAX_MANIFEST_BYTES`. + await writeSkills([skill('a')], root); + disarm(); + + await writeSkills([skill('a')], root); + + expect(hook.wholeFileReads.has('.launchdarkly-skills.json')).toBe(false); + expect(hook.reads.get('.launchdarkly-skills.json')).toBeGreaterThan(0); + }); +}); + +// ─── The adoption comparison hashes the delivered rule ─────────────────────── + +/** + * Adoption compares an on-disk file against `contentHash` using its *own* + * expression — `createHash('sha256').update(onDisk)` in `skills-fs.ts` — rather + * than going through `verifiedBytes`. So it is a second place the content-hash + * rule is written down, and the accessor-side contract test cannot reach it. + * + * It is also the one that decides whether an unmanaged file is claimed or + * refused. A comparison that normalized would adopt a planted file whose bytes + * merely *normalize* to the delivered content, leave those foreign bytes on + * disk, and report `skipped_current` — the bytes on disk would not be the bytes + * LaunchDarkly delivered, which is precisely what that action claims. + */ +describe('the adoption comparison hashes the delivered rule', () => { + /** + * The same vector and digest pinned in `skills.test.ts`, deliberately copied + * rather than shared: every test file here defines its own helpers, and the + * value of a literal is that it is not derived from anything. Both copies are + * the digest LaunchDarkly's delivery service computes for this content, so + * neither may be replaced by a call to `hash` and the two only ever change + * together, and only if the service's rule changed. + * + * `\u00e9` as an escape rather than the character itself, so a tool that + * re-normalized this source file could not quietly change the input. + */ + const HAZARD_CONTENT = '# h\u00e9llo\r\n\ntrailing no newline'; + const HAZARD_DIGEST = '2b7c050d94135e5e947263053ebde1c988c2bb90bcc037e8f3d1a2d340b9a558'; + + /** The post-crash state adoption exists for: a managed path, no manifest. */ + async function placeOrphaned(content: string | Uint8Array): Promise { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content); + return target; + } + + /** + * A skill carrying the pinned digest as a literal. + * + * `writeSkills` re-verifies through `verifiedBytes` before it ever looks at + * the disk, so this pins the write path's copy of the rule too: a skill built + * this way only survives to the adoption comparison if the digest still + * matches the content's exact bytes. + */ + const pinned = () => + createSkill({ + key: 'a', + version: 1, + content: new TextEncoder().encode(HAZARD_CONTENT), + contentHash: HAZARD_DIGEST, + }); + + const manifestEntries = async (): Promise> => + JSON.parse(await readFile(path.join(root, '.launchdarkly-skills.json'), 'utf-8')).entries; + + it('adopts a file whose bytes are exactly the pinned content', async () => { + // The positive control, and the half that fails if either this SDK or the + // delivery service starts canonicalizing: the file on disk is byte-for-byte + // what the pinned digest is the digest of. + await placeOrphaned(HAZARD_CONTENT); + + const report = await writeSkills([pinned()], root); + + expect(report.ok).toBe(true); + expect(report.actions.find((a) => a.key === 'a')?.action).toBe('skipped_current'); + expect(Object.keys(await manifestEntries())).toEqual(['a/SKILL.md']); + }); + + // The same three fixups the accessor-side contract test applies, here against + // the file on disk: an editor rewriting line endings, a lint step adding the + // final newline, a normalization form decomposing the accent. None changes + // what the document means and all three change its bytes, so none of them is + // the content LaunchDarkly delivered. + const normalizations: Array<[string, (text: string) => string]> = [ + ['CRLF rewritten to LF', (text) => text.replace(/\r\n/g, '\n')], + ['a trailing newline appended', (text) => `${text}\n`], + ['NFD decomposition', (text) => text.normalize('NFD')], + ]; + + it.each(normalizations)('refuses a planted file with %s, rather than adopting it', async (_label, normalize) => { + const planted = normalize(HAZARD_CONTENT); + expect(planted, 'the transform left this input alone').not.toBe(HAZARD_CONTENT); + const target = await placeOrphaned(planted); + + const report = await writeSkills([pinned()], root); + + expect(report.ok).toBe(false); + const action = report.actions.find((a) => a.key === 'a'); + expect(action?.action).toBe('error'); + expect(action?.error).toContain('does not record it as managed'); + // Refused after looking, not because the read failed — the same distinction + // the bounded-read cases above rely on. + expect(action?.error).not.toContain('could not be read'); + // Neither adopted nor overwritten: the planted bytes are still the planted + // bytes, and nothing was claimed into the manifest. + expect(await readFile(target, 'utf-8')).toBe(planted); + expect(await manifestEntries()).toEqual({}); + }); +}); + +// ─── The bytes that were hashed are the bytes that get written ─────────────── + +/** + * `createSkill` freezes the wrapper, but a TypedArray's elements cannot be frozen + * — `Object.freeze` throws on an array-buffer view — so a `Skill` shares its + * buffer with whoever constructed it, and `types.ts` says so in terms. + * + * On the write path the hash is computed in `verifiedBytes` and the rename + * happens several `await`s later: the existence check and, when the target + * exists, the comparison read sit in between. So without a snapshot, a caller + * mutating `skill.content` inside that window writes bytes that were never + * hashed, and verify-then-write verifies nothing. + * + * This is a TypeScript-shaped hazard. Python's `bytes` are immutable, so the + * guarantee is free there and its test asserts a property rather than a guard. + */ +describe('content is snapshotted before it is hashed', () => { + /** + * Both buffer kinds a caller can legally hand to `createSkill`. + * + * `Buffer` is not a curiosity here, it is the trap: it extends `Uint8Array`, so + * it satisfies `Skill.content` without a cast, and `Buffer.prototype.slice` is + * the legacy spelling that returns a *view* rather than a copy. A snapshot + * written as `content.slice()` therefore protects a plain `Uint8Array` and + * silently fails to protect a `Buffer` — present in the diff, absent in effect. + * A pooled `Buffer` (a `subarray` into a larger allocation, which is what + * `Buffer.from(string)` hands out for small strings anyway) is the third shape, + * because a copy that respected `byteLength` but not `byteOffset` would pass the + * other two. + */ + const bufferKinds: Array<[string, (bytes: Uint8Array) => Uint8Array]> = [ + ['Uint8Array', (bytes) => Uint8Array.from(bytes)], + ['Buffer', (bytes) => Buffer.from(bytes)], + [ + 'pooled Buffer view', + (bytes) => { + const pool = Buffer.alloc(bytes.byteLength + 8, 0x7a); + pool.set(bytes, 4); + return pool.subarray(4, 4 + bytes.byteLength); + }, + ], + ]; + + it.each(bufferKinds)('a %s mutated between the hash and the write does not reach the file', async (_kind, make) => { + const original = new TextEncoder().encode(SKILL_BODY); + // The caller's own buffer, which `createSkill` will alias. + const mutable = make(original); + const contentHash = hash(original); + const skill = createSkill({ key: 'a', version: 1, content: mutable, contentHash }); + // The alias is real — otherwise this test is about nothing. + expect(skill.content).toBe(mutable); + + // `mkdir` of `/a` runs after `verifiedBytes` has hashed and before + // `atomicWrite` writes a byte, which is exactly the window. Fired from the + // filesystem hook rather than from implementation internals, so the test is + // not coupled to how the snapshot is taken. + const tampered = new TextEncoder().encode('---\nname: Tampered\n---\nDo something else.\n'); + Object.assign(hook, { + onMkdir: 'a', + fire: () => { + for (let i = 0; i < mutable.byteLength; i += 1) mutable[i] = tampered[i % tampered.byteLength]; + }, + fired: false, + }); + + const report = await writeSkills([skill], root); + + if (!hook.fired) throw new Error(NEVER_FIRED); + // The mutation landed on the caller's buffer, so the window was real. + expect(Buffer.from(mutable)).not.toEqual(Buffer.from(original)); + + expect(report.ok).toBe(true); + const action = report.actions.find((a) => a.key === 'a'); + expect(action?.action).toBe('written'); + + // The file holds the bytes that were hashed, not the bytes the buffer held + // when the write happened. + const onDisk = await readFile(path.join(root, 'a', SKILL_MD)); + expect(Buffer.from(onDisk)).toEqual(Buffer.from(original)); + // And the hash recorded for it is the hash *of those bytes* — the two halves + // agree, which is the whole claim. + const entries = JSON.parse(await readFile(path.join(root, '.launchdarkly-skills.json'), 'utf-8')).entries; + expect(entries[`a/${SKILL_MD}`].sha256).toBe(contentHash); + expect(hash(onDisk)).toBe(contentHash); + }); + + it('a buffer mutated before writeSkills is called fails verification instead', async () => { + // The mirror image, and the reason the snapshot is not a licence to ignore + // the caller: bytes that disagree with `contentHash` when `writeSkills` is + // *entered* are a verification failure, not something to snapshot and write. + const mutable = Uint8Array.from(new TextEncoder().encode(SKILL_BODY)); + const skill = createSkill({ key: 'a', version: 1, content: mutable, contentHash: hash(SKILL_BODY) }); + mutable[0] = 0x58; + + const report = await writeSkills([skill], root); + + expect(report.ok).toBe(false); + const action = report.actions.find((a) => a.key === 'a'); + expect(action?.action).toBe('error'); + expect(action?.error).toContain('failed verification'); + }); +}); diff --git a/packages/client/src/__tests__/skills-fs-root-swap.test.ts b/packages/client/src/__tests__/skills-fs-root-swap.test.ts new file mode 100644 index 00000000..dfc3a20b --- /dev/null +++ b/packages/client/src/__tests__/skills-fs-root-swap.test.ts @@ -0,0 +1,383 @@ +/** + * `writeSkills` — the swap one level up: the managed *root* replaced by a + * symlink, not `/`. + * + * `O_NOFOLLOW` guards only the final path component, so opening `/` + * by path would re-resolve the root on every open, and a root swapped for a + * symlink after validation would redirect the write into the attacker's + * directory. The precondition is write permission on the root's *parent* — in + * the documented layout (`/.claude/skills`) that is `.claude`, which the + * agent identity typically owns. + * + * `writeSkills` pins the root to a descriptor for the whole reconcile and + * addresses every child as `/proc/self/fd//`, which the kernel + * resolves against the pinned inode. That exists on Linux only, so this file is + * gated on `SUPPORTS_PROC_FD`; elsewhere the per-component `lstat` floor applies + * and the mitigation is the README's privilege-separation checklist. + * + * Every test asserts the contract: nothing lands outside the root, no outside + * file is overwritten or removed — **and**, where the swap lands after the + * containment check, that the operation went through in the pinned root (so an + * implementation that refuses every write after a swap does not pass). + * + * In its own file because the swap has to land *before* the per-skill directory + * is opened, which means intercepting `mkdir` and `open` from `node:fs/promises` + * rather than the `fsOps` rename/unlink hook, and `vi.mock` is file-wide. + */ + +import { createHash } from 'node:crypto'; +import { lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SUPPORTS_PROC_FD } from '../safe-fs.js'; +import { _clearState } from '../skills.js'; +import { writeSkills } from '../skills-fs.js'; +import type { Skill } from '../types.js'; +import { createSkill } from '../types.js'; + +const SKILL_MD = 'SKILL.md'; +const MANIFEST_NAME = '.launchdarkly-skills.json'; +const SKILL_BODY = '---\nname: Test Skill\n---\nDo the thing.\n'; +const NEVER_FIRED = 'the race never fired; the test proves nothing'; + +// ─── The mkdir/open interception hook ──────────────────────────────────────── +// +// Pass-throughs until a test arms one of them; everything else +// `node:fs/promises` exports is the real thing. `nth` picks which matching call +// fires, because a run can open the same skill directory more than once and only +// the open *after* the last containment check is the one that counts. +// +// The trigger matches on the operation's **final component plus the shape of its +// parent**, not on one exact string: the SDK addresses `/` as +// `/proc/self/fd//`, so a trigger pinned to `path.join(root, key)` +// would never fire and the suite would pass vacuously. + +const race = vi.hoisted(() => ({ + /** Which operation the swap rides on, or `null` when disarmed. */ + on: null as 'mkdir' | 'open' | null, + /** `'child'` matches an operation on a child of the root; `'root'` the root open itself. */ + mode: 'child' as 'child' | 'root', + /** For `'child'`: the final component the operation must target. */ + name: '', + /** The realpath'd managed root — the parent a path-addressed child carries. */ + root: '', + nth: 1, + seen: 0, + fire: null as (() => Promise) | null, + fired: false, +})); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + const call = (fn: unknown, args: unknown[]) => (fn as (...a: unknown[]) => Promise)(...args); + const PROC = '/proc/self/fd'; + + function matches(op: 'mkdir' | 'open', target: unknown): boolean { + if (race.on !== op || race.fired || typeof target !== 'string') return false; + if (race.mode === 'root') return target === race.root; + if (path.basename(target) !== race.name) return false; + // A child of the managed root, named either through the root's own path + // or through a held descriptor. + const parent = path.dirname(target); + return parent === race.root || parent.startsWith(`${PROC}/`); + } + + async function maybeFire(op: 'mkdir' | 'open', target: unknown): Promise { + if (!matches(op, target)) return; + race.seen += 1; + if (race.seen < race.nth) return; + race.fired = true; + await race.fire?.(); + } + + return { + ...actual, + mkdir: async (...args: unknown[]) => { + await maybeFire('mkdir', args[0]); + return call(actual.mkdir, args); + }, + open: async (...args: unknown[]) => { + await maybeFire('open', args[0]); + return call(actual.open, args); + }, + }; +}); + +/** Fires `fire` on the `nth` `op` targeting `/`. */ +function arm(on: 'mkdir' | 'open', name: string, fire: () => Promise, nth = 1): void { + Object.assign(race, { on, mode: 'child', name, nth, seen: 0, fire, fired: false }); +} + +/** Fires `fire` on the open of the managed root itself — i.e. just before it is pinned. */ +function armRootOpen(fire: () => Promise): void { + Object.assign(race, { on: 'open', mode: 'root', name: '', nth: 1, seen: 0, fire, fired: false }); +} + +function disarm(): void { + Object.assign(race, { on: null, mode: 'child', name: '', nth: 1, seen: 0, fire: null, fired: false }); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Convenience for building fixtures whose `contentHash` is correct. + * + * Deliberately the same expression the implementation hashes with, which is + * what makes it useless as an oracle: a change to the hashing rule moves every + * fixture built here along with it, and nothing in this file would fail. The + * rule is pinned independently, against the digests LaunchDarkly's delivery + * service computes, in the `contentHash contract` suite in `skills.test.ts`. + */ +function hash(content: string): string { + return createHash('sha256').update(Buffer.from(content, 'utf-8')).digest('hex'); +} + +function skill(key: string, version = 1, content = SKILL_BODY): Skill { + return createSkill({ key, version, content: new TextEncoder().encode(content), contentHash: hash(content) }); +} + +async function writeManifest(root: string, raw: unknown): Promise { + await writeFile(path.join(root, MANIFEST_NAME), JSON.stringify(raw), 'utf-8'); +} + +/** Pre-create a file AND its manifest entry — i.e. an SDK-managed path. */ +async function placeManaged(root: string, key: string, content: string, version = 1): Promise { + await mkdir(path.join(root, key), { recursive: true }); + await writeFile(path.join(root, key, SKILL_MD), content, 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + entries: { [`${key}/${SKILL_MD}`]: { key, version, sha256: hash(content), writtenAt: '2026-08-14T19:00:00Z' } }, + }); +} + +async function exists(target: string): Promise { + try { + await lstat(target); + return true; + } catch { + return false; + } +} + +// ─── Per-test scratch directory ────────────────────────────────────────────── + +let scratch: string; +let root: string; + +/** Where the real root ends up after the swap. */ +const movedTo = (): string => `${root}.real`; + +/** + * The `entries` map of the manifest in the root that was actually pinned. + * + * After a swap that is the moved-aside directory, which is where every + * descriptor-addressed operation — including the manifest rewrite — lands. + */ +async function realManifestEntries(): Promise> { + const raw = JSON.parse(await readFile(path.join(movedTo(), MANIFEST_NAME), 'utf-8')); + return raw.entries as Record; +} + +/** Renames the managed root aside and leaves a symlink to `outside` in its place. */ +async function swapRoot(outside: string): Promise { + await rename(root, movedTo()); + await symlink(outside, root, 'dir'); +} + +beforeEach(async () => { + _clearState(); + disarm(); + // realpath, as in skills-fs.test.ts: writeSkills resolves the root, and the + // trigger has to compare equal to the path the implementation actually opens. + scratch = await realpath(await mkdtemp(path.join(tmpdir(), 'ld-ai-skills-root-swap-'))); + root = path.join(scratch, 'skills'); + await mkdir(root); + race.root = root; +}); + +afterEach(async () => { + disarm(); + _clearState(); + await rm(scratch, { recursive: true, force: true }); +}); + +// ─── The three root-swap races ─────────────────────────────────────────────── + +describe.skipIf(!SUPPORTS_PROC_FD)('writeSkills root swap races', () => { + it('a root swapped at the skill directory create cannot redirect the write', async () => { + // First reconcile against a fresh root: `/a` does not exist, so + // `openOrCreateDirectory` calls `mkdir(/a)`. The swap fires there; + // the mkdir, the O_NOFOLLOW open and assertUnswapped's lstat all resolve + // through the link, and SKILL.md is written into `/a/`. + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + arm('mkdir', 'a', () => swapRoot(outside)); + + const report = await writeSkills([skill('a')], root); + + if (!race.fired) throw new Error(NEVER_FIRED); + expect(await readdir(outside)).toEqual([]); + // Unconditionally: guarding this on `report.ok` would pass an + // implementation that refuses every write after a swap. The swap fires + // after `unsafePathReason` has run, so what is under test is the descriptor + // addressing, which must deliver the file into the validated root. + expect(report.ok).toBe(true); + expect(await readFile(path.join(movedTo(), 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('a root swapped at the skill directory open cannot clobber an outside file', async () => { + // Update of an already-managed skill: `/a` exists, so the mkdir + // fails EEXIST and the swap fires at the O_NOFOLLOW open that follows. + // `/a/SKILL.md` — a file the manifest never recorded — is + // replaced with LaunchDarkly-served content. + const outside = path.join(scratch, 'outside'); + await mkdir(path.join(outside, 'a'), { recursive: true }); + const victim = path.join(outside, 'a', SKILL_MD); + await writeFile(victim, 'precious\n', 'utf-8'); + await placeManaged(root, 'a', SKILL_BODY); + arm('open', 'a', () => swapRoot(outside)); + + const report = await writeSkills([skill('a', 2, 'served update\n')], root); + + if (!race.fired) throw new Error(NEVER_FIRED); + expect(await readFile(victim, 'utf-8')).toBe('precious\n'); + // Unconditional for the reason given above: "the victim is unchanged" holds + // for an implementation that wrote nothing at all, so the landing has to be + // asserted too. + expect(report.ok).toBe(true); + expect(await readFile(path.join(movedTo(), 'a', SKILL_MD), 'utf-8')).toBe('served update\n'); + }); + + it('a root swapped at the prune cannot redirect the unlink', async () => { + // The destructive side. The orphan-temp sweep opens `/a` first and + // `pruneOne` re-runs the realpath containment check after it, so a swap + // at that open would be caught; the second open — pruneOne's own, after + // the check — is not. The unlink and the rmdir then both resolve through + // the swapped root, and an outside file and its directory are removed. + const outside = path.join(scratch, 'outside'); + await mkdir(path.join(outside, 'a'), { recursive: true }); + const victim = path.join(outside, 'a', SKILL_MD); + await writeFile(victim, 'precious\n', 'utf-8'); + await placeManaged(root, 'a', SKILL_BODY); + arm('open', 'a', () => swapRoot(outside), 2); + + const report = await writeSkills([], root); + + if (!race.fired) throw new Error(NEVER_FIRED); + expect(await exists(victim)).toBe(true); + expect(await readFile(victim, 'utf-8')).toBe('precious\n'); + // Unconditional, and on the destructive side it matters most: an + // implementation that pruned nothing satisfies "the outside file survives" + // perfectly. The managed file — the one this SDK owns — is the one that had + // to go, and the report has to say so. + expect(report.ok).toBe(true); + expect(report.actions.map((action) => [action.key, action.action])).toContainEqual(['a', 'removed']); + expect(await exists(path.join(movedTo(), 'a', SKILL_MD))).toBe(false); + }); + + /** + * The manifest read, which is not itself a destructive step but decides every + * destructive step. + * + * An implementation that read the manifest by path would pass every other case + * in this file, but `rewriteManifest` would commit the attacker's entries over + * the real manifest, destroying the ownership record for every later run. + * + * These do not assert that the run succeeds: `unsafePathReason`'s containment + * check also catches the swapped root, so both runs refuse (fail-closed). + * Instead they assert **whose entries the run acted on** — the real key + * present and the attacker's key absent, in the report and the rewritten + * manifest; either alone would pass against an implementation that read + * neither. + */ + /** The attacker's own manifest, naming a key the real root never managed. */ + async function plantForeignManifest(outside: string): Promise { + await mkdir(path.join(outside, 'zz'), { recursive: true }); + await writeFile(path.join(outside, 'zz', SKILL_MD), 'attacker planted\n', 'utf-8'); + await writeManifest(outside, { + manifestVersion: 1, + entries: { + [`zz/${SKILL_MD}`]: { + key: 'zz', + version: 1, + sha256: hash('attacker planted\n'), + writtenAt: '2026-08-14T19:00:00Z', + }, + }, + }); + } + + it('a root swapped at the manifest read reconciles from the real entries, not the attacker’s', async () => { + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + await plantForeignManifest(outside); + await placeManaged(root, 'a', SKILL_BODY); + arm('open', MANIFEST_NAME, () => swapRoot(outside)); + + const report = await writeSkills([skill('a', 2, 'served update\n')], root); + + if (!race.fired) throw new Error(NEVER_FIRED); + // The real manifest's key is the one the run reasoned about... + expect(report.actions.map((action) => action.key)).toContain('a'); + // ...and the attacker's key never entered the run. Were their manifest read, + // `zz` is not in the requested set, so prune would have reported it. + expect(report.actions.some((action) => action.key === 'zz')).toBe(false); + + // The ownership record survives in the real root: still `a`, never `zz`. + const entries = await realManifestEntries(); + expect(Object.keys(entries)).toEqual([`a/${SKILL_MD}`]); + expect((entries[`a/${SKILL_MD}`] as { key: string }).key).toBe('a'); + // And nothing was done to the attacker's directory. + expect(await readFile(path.join(outside, 'zz', SKILL_MD), 'utf-8')).toBe('attacker planted\n'); + }); + + it('a root swapped at the manifest read prunes from the real entries, not the attacker’s', async () => { + // Same property on the destructive path, where reading the wrong manifest + // would aim the prune: `zz` is unrequested, so the attacker's entry is + // precisely what a prune driven by their manifest would go after. + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + await plantForeignManifest(outside); + await placeManaged(root, 'a', SKILL_BODY); + arm('open', MANIFEST_NAME, () => swapRoot(outside)); + + const report = await writeSkills([], root); + + if (!race.fired) throw new Error(NEVER_FIRED); + expect(report.actions.map((action) => action.key)).toContain('a'); + expect(report.actions.some((action) => action.key === 'zz')).toBe(false); + + // The prune was refused by containment, so `a` stays listed — and `zz` was + // never listed. The next run, against an unswapped root, prunes `a`. + const entries = await realManifestEntries(); + expect(Object.keys(entries)).toEqual([`a/${SKILL_MD}`]); + expect(await readFile(path.join(outside, 'zz', SKILL_MD), 'utf-8')).toBe('attacker planted\n'); + }); + + it('a root swapped before it is pinned is refused with a run-level error', async () => { + // The one window pinning cannot cover, and the reason the pin is not the + // whole story: `resolveRoot` validated the root's *name*, and the swap lands + // between that and the open that turns the name into a descriptor. The + // O_NOFOLLOW open then fails on the symlink. + // + // Refused as a run-level `error` action rather than thrown. `resolveRoot` + // throws because an unusable root is a caller mistake; a root that was a + // real directory an instant ago and is a symlink now is an attack in + // flight, and it belongs in the report next to every other refusal. + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + await placeManaged(root, 'a', SKILL_BODY); + armRootOpen(() => swapRoot(outside)); + + const report = await writeSkills([skill('a', 2, 'served update\n')], root); + + if (!race.fired) throw new Error(NEVER_FIRED); + expect(report.ok).toBe(false); + expect(report.errors.some((action) => action.key === '')).toBe(true); + // Nothing was attempted through the link, and the real root is untouched. + expect(await readdir(outside)).toEqual([]); + expect(await readFile(path.join(movedTo(), 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); +}); diff --git a/packages/client/src/__tests__/skills-fs.test.ts b/packages/client/src/__tests__/skills-fs.test.ts new file mode 100644 index 00000000..7c2a959c --- /dev/null +++ b/packages/client/src/__tests__/skills-fs.test.ts @@ -0,0 +1,2901 @@ +/** + * `writeSkills` — filesystem materialization, manifest reconcile semantics, and + * the full security abuse matrix. + * + * Every test writes only inside its own `os.tmpdir()` scratch directory. No + * network, no real LaunchDarkly client, no real skill transport. + */ + +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { constants as fsConstants, realpathSync } from 'node:fs'; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + rmdir, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { fsOps, SUPPORTS_DIR_FD, SUPPORTS_PROC_FD } from '../safe-fs.js'; +import { _clearState, _setEmitterForTesting, _setStore, InMemorySkillStore, skillRefs } from '../skills.js'; +import { MAX_SKILL_CONTENT_BYTES, recordRevoked } from '../skills-core.js'; +import { MAX_MANIFEST_BYTES, writeSkills } from '../skills-fs.js'; +import type { RawSkillObject, ReconcileAction, ReconcileReport, Skill, SkillStore } from '../types.js'; +import { createSkill, isValidSkillKey, parseAiConfig } from '../types.js'; + +// ─── Constants spelled out by hand ─────────────────────────────────────────── +// +// These two strings are a cross-language on-disk contract, so +// the filesystem tests write them literally. A test that imported the +// implementation's own constant could not detect a change to it. + +const SKILL_MD = 'SKILL.md'; +const MANIFEST_NAME = '.launchdarkly-skills.json'; + +const SKILL_BODY = '---\nname: Test Skill\n---\nDo the thing.\n'; +const INJECTED = 'simulated crash between write and rename'; + +/** One over-cap string for the whole file: 10 MiB costs real time to allocate and hash. */ +const OVERSIZE = 'x'.repeat(MAX_SKILL_CONTENT_BYTES + 1); + +const INTEGRITY_SIGNAL = 'AgentControl Skill Integrity Failure'; +const MATERIALIZED_SIGNAL = 'AgentControl Skill Materialized'; +const REVOKED_SIGNAL = 'AgentControl Skill Revoked Received'; + +const APPROVED_SIGNALS = new Set([INTEGRITY_SIGNAL, MATERIALIZED_SIGNAL, REVOKED_SIGNAL]); +const REMOVED_SIGNALS = ['AgentControl Skill SDK Reference Returned', 'AgentControl Skill Content Retrieved']; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Convenience for building fixtures whose `contentHash` is correct. + * + * Deliberately the same expression the implementation hashes with, which is + * what makes it useless as an oracle: a change to the hashing rule moves every + * fixture built here along with it, and nothing in this file would fail. The + * rule is pinned independently, against the digests LaunchDarkly's delivery + * service computes, in the `contentHash contract` suite in `skills.test.ts`. + */ +function hash(content: string): string { + return createHash('sha256').update(Buffer.from(content, 'utf-8')).digest('hex'); +} + +function skill(key = 'test-skill', version = 1, content = SKILL_BODY): Skill { + // A `Skill` carries the verified bytes; the string form here is only test + // convenience, hashed identically to its UTF-8 encoding. + return createSkill({ key, version, content: new TextEncoder().encode(content), contentHash: hash(content) }); +} + +function rawSkill(key: string, version = 1, content = SKILL_BODY): RawSkillObject { + return { key, version, content, contentHash: hash(content) }; +} + +function manifestPath(root: string): string { + return path.join(root, MANIFEST_NAME); +} + +async function readManifest(root: string): Promise> { + return JSON.parse(await readFile(manifestPath(root), 'utf-8')); +} + +async function writeManifest(root: string, raw: unknown): Promise { + await mkdir(root, { recursive: true }); + await writeFile(manifestPath(root), typeof raw === 'string' ? raw : JSON.stringify(raw), 'utf-8'); +} + +function manifestEntry(key: string, version: number, content: string): Record { + return { key, version, sha256: hash(content), writtenAt: '2026-08-14T19:00:00Z' }; +} + +/** Pre-create a file AND its manifest entry — i.e. an SDK-managed path. */ +async function placeManaged(root: string, key: string, content: string, version = 1): Promise { + const target = path.join(root, key, SKILL_MD); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + entries: { [`${key}/${SKILL_MD}`]: manifestEntry(key, version, content) }, + }); + return target; +} + +function actionsByKey(report: ReconcileReport): Record { + return Object.fromEntries(report.actions.map((a) => [a.key, a])); +} + +/** + * All `error` action messages, regardless of which key they hang off. + * + * Run-level (manifest) errors carry the empty-string key sentinel, so + * assertions about them scan every error action rather than looking one up. + */ +function errorMessages(report: ReconcileReport): string[] { + return report.errors.map((a) => a.error ?? ''); +} + +async function exists(target: string): Promise { + try { + await lstat(target); + return true; + } catch { + return false; + } +} + +async function entryNames(dir: string): Promise { + return (await readdir(dir)).sort(); +} + +class RecordingEmitter { + records: Array<[string, Record]> = []; + record(signal: string, properties: Record): void { + this.records.push([signal, properties]); + } + signals(name: string): Array> { + return this.records.filter(([s]) => s === name).map(([, p]) => p); + } + names(): Set { + return new Set(this.records.map(([s]) => s)); + } +} + +// ─── The rename/unlink interception hook ───────────────────────────────────── + +type RenameCall = { src: string; dst: string }; + +/** + * A descriptor-addressed path, resolved back to the real one. + * + * On Linux the implementation issues its operations against + * `/proc/self/fd//`, so the kernel resolves them from a pinned inode + * rather than from a name — see `SUPPORTS_PROC_FD` in `safe-fs.ts`. These tests + * assert on *where* an operation landed, which means undoing that addressing, and + * it can only be undone while the descriptor is still open. Hence inside the + * interception rather than in the assertion. + * + * Only the parent is resolved: the final component of a rename destination does + * not exist yet. Off the fast path this is the identity function. + */ +function realize(target: string): string { + if (!target.startsWith('/proc/self/fd/')) return target; + return path.join(realpathSync(path.dirname(target)), path.basename(target)); +} + +/** + * Records — and optionally fails — every atomic rename of a `SKILL.md`. + * + * The implementation performs the + * final rename through the single `fsOps.rename` call site, so spying on that + * property observes it. Destinations other than `SKILL.md` — the manifest's own + * atomic write — pass straight through. + * + * Used two ways: to prove an injected failure is what produced an `error` action + * (atomicity), and to prove no write was *attempted* for a rejected key + * — the OS would reject several hostile keys on its own, so a failed + * write is not evidence of a defense. + */ +function interceptRename(options: { fail?: boolean } = {}): RenameCall[] { + const calls: RenameCall[] = []; + const real = fsOps.rename.bind(fsOps); + vi.spyOn(fsOps, 'rename').mockImplementation(async (src: string, dst: string) => { + if (dst.endsWith(SKILL_MD)) { + calls.push({ src: realize(src), dst: realize(dst) }); + if (options.fail) throw new Error(INJECTED); + } + return real(src, dst); + }); + return calls; +} + +/** The prune-side hook — one interceptable call site, same filter. */ +function interceptUnlink(options: { fail?: boolean } = {}): string[] { + const calls: string[] = []; + const real = fsOps.unlink.bind(fsOps); + vi.spyOn(fsOps, 'unlink').mockImplementation(async (target: string) => { + if (target.endsWith(SKILL_MD)) { + calls.push(realize(target)); + if (options.fail) throw new Error(INJECTED); + } + return real(target); + }); + return calls; +} + +/** + * Assert the one recorded rename put `SKILL.md` into `skillDir`. + * + * The temp file must be created in the target's own + * directory, so the rename is atomic rather than cross-device. TypeScript always + * sees the **full-path** call shape, + * because Node exposes no `renameat` (see the `SUPPORTS_DIR_FD` test below). + */ +function assertAtomicRenameOf(calls: RenameCall[], skillDir: string): void { + expect(calls).toHaveLength(1); + const { src, dst } = calls[0]; + expect(dst).toBe(path.join(skillDir, SKILL_MD)); + expect(path.dirname(src)).toBe(skillDir); + expect(path.basename(src)).not.toBe(SKILL_MD); +} + +// ─── Per-test scratch directory ────────────────────────────────────────────── + +let scratch: string; +let root: string; + +beforeEach(async () => { + _clearState(); + // realpath, because writeSkills resolves the + // managed root and every path it reports is therefore canonical. On macOS + // os.tmpdir() is `/var/folders/...`, a symlink to `/private/var/folders/...`, + // so a harness that kept the unresolved form would compare a canonical path + // against a non-canonical one and fail on the platform difference rather than + // on anything the SDK did. + scratch = await realpath(await mkdtemp(path.join(tmpdir(), 'ld-ai-skills-'))); + root = path.join(scratch, 'skills'); + await mkdir(root); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + _clearState(); + await rm(scratch, { recursive: true, force: true }); +}); + +// ─── Basic writes and the returned report ────────────────────────────── + +describe('writeSkills basic writes', () => { + it('writes a new skill verbatim and reports written', async () => { + const report = await writeSkills([skill('pdf-extraction', 2)], root); + + const target = path.join(root, 'pdf-extraction', SKILL_MD); + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + expect(report.ok).toBe(true); + const action = actionsByKey(report)['pdf-extraction']; + expect(action.action).toBe('written'); + expect(action.version).toBe(2); + expect(action.path).toBe(target); + }); + + it('accepts Skill inputs with no store configured', async () => { + const report = await writeSkills([skill('a')], root); + expect(report.ok).toBe(true); + }); + + it('resolves SkillReference inputs through the store', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill('a', 3)); + _setStore(store); + + const report = await writeSkills([{ key: 'a', version: 3 }], root); + + expect(report.ok).toBe(true); + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('resolves bare-string inputs as the latest version', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill('a', 9)); + _setStore(store); + + const report = await writeSkills(['a'], root); + + expect(actionsByKey(report).a.version).toBe(9); + }); + + it('"*" writes everything the store holds', async () => { + const store = new InMemorySkillStore(); + for (const key of ['a', 'b', 'c']) store.put(rawSkill(key)); + _setStore(store); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(true); + expect(report.actions.filter((a) => a.action !== 'error')).toHaveLength(3); + }); + + it('"*" collapses several versions of one key to the newest', async () => { + // //SKILL.md is a single path, so two versions of one key is not + // a duplicate report but a write race against itself, resolved by whichever + // version iteration happened to reach last. A store holding one version per + // key cannot distinguish this from "write everything", so the two-version + // seed is the whole test. Newer seeded first, so insertion order cannot pass + // for ordering. + const newer = 'newer body\n'; + const store = new InMemorySkillStore(); + store.put(rawSkill('a', 5, newer)); + store.put(rawSkill('a', 2, 'older body\n')); + _setStore(store); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(true); + const written = report.actions.filter((a) => a.action !== 'error'); + expect(written).toHaveLength(1); + expect(written[0].key).toBe('a'); + expect(written[0].version).toBe(5); + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(newer); + }); + + it('"*" reports no error for a key a malformed sibling did not stop writing', async () => { + // A malformed object beside a good version of the same key. The good one + // resolves and materializes, so the run succeeded for that key: reporting + // the sibling as well would flip `report.ok` for a skill that is correctly + // on disk, and claim the copy there "was left alone" when this very run had + // just written it. + const store = new InMemorySkillStore(); + store.put(rawSkill('a', 1)); + store.put(rawSkill('a', 'nope' as unknown as number)); + _setStore(store); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(true); + expect(report.actions.map((a) => [a.key, a.action])).toEqual([['a', 'written']]); + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('"*" still reports a key nothing could resolve, and prunes nothing', async () => { + // The converse, and the reason a malformed object is kept at all. No + // version of 'b' is usable, so it stays in the requested set: the failure is + // reported, and prune leaves the copy already on disk alone rather than + // reading the key as revoked. + const existing = await placeManaged(root, 'b', SKILL_BODY); + const store = new InMemorySkillStore(); + store.put(rawSkill('a', 1)); + store.put(rawSkill('b', 'nope' as unknown as number)); + _setStore(store); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('written'); + expect(actionsByKey(report).b.action).toBe('error'); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + }); + + it('reports one action per requested skill — no silent skips', async () => { + const report = await writeSkills([skill('a'), skill('b')], root); + expect(report.actions.map((a) => a.key).sort()).toEqual(['a', 'b']); + }); + + it('an empty request on an empty root is ok', async () => { + const report = await writeSkills([], root); + expect(report.ok).toBe(true); + }); +}); + +// ─── Manifest ────────────────────────────────────────────────────────── + +describe('writeSkills manifest', () => { + it('has the exact cross-language format', async () => { + await writeSkills([skill('a', 2)], root); + + const manifest = await readManifest(root); + expect(manifest.manifestVersion).toBe(1); + const entries = manifest.entries as Record>; + const entry = entries[`a/${SKILL_MD}`]; + expect(entry).toBeDefined(); + expect(entry.key).toBe('a'); + expect(entry.version).toBe(2); + expect(entry.sha256).toBe(hash(SKILL_BODY)); + expect(typeof entry.writtenAt).toBe('string'); + }); + + it('serializes to the shared on-disk form', async () => { + // Not a correctness requirement — every implementation parses either form — + // but a root that more than one SDK reconciles would otherwise see the key + // order flip on every run depending on which wrote last. The agreed form is + // a two-space indent, sorted keys, and no trailing newline. + await writeManifest(root, { zeta: 'unknown field', manifestVersion: 1, entries: {}, alpha: 1 }); + await writeSkills([skill('b')], root); + await writeSkills([skill('b'), skill('a')], root); + + const raw = await readFile(manifestPath(root), 'utf-8'); + expect(raw.endsWith('\n')).toBe(false); + expect(raw).toContain('\n "'); + const topLevel = [...raw.matchAll(/^ {2}"([^"]+)":/gm)].map((m) => m[1]); + expect(topLevel).toEqual([...topLevel].sort()); + // Nested keys are sorted too, so entry order does not depend on write order. + const entryPaths = Object.keys((await readManifest(root)).entries as object); + expect(entryPaths).toEqual([...entryPaths].sort()); + }); + + it('escapes non-ASCII to the shared on-disk form', async () => { + // The manifest's bytes are a cross-SDK contract, and the agreed form escapes + // every non-ASCII character as \uXXXX. JSON.stringify emits raw UTF-8 + // instead, so without the escape two SDKs would rewrite the same file with + // different bytes on alternating reconciles — the churn the sorted-key rule + // already exists to prevent. + // + // Every field the SDK writes is ASCII by construction, so this is only + // reachable through the preserved-unknown-field path — which is why + // the seeded field is what carries the non-ASCII. + await writeManifest(root, { + manifestVersion: 1, + entries: {}, + note: 'café ☕ 😀', + }); + + await writeSkills([skill('b')], root); + + const raw = await readFile(manifestPath(root), 'utf-8'); + // Escaped form, astral characters as a surrogate pair — byte-for-byte what + // the other implementations emit for the same value. + expect(raw).toContain('"note": "caf\\u00e9 \\u2615 \\ud83d\\ude00"'); + expect(raw).not.toContain('café'); + // Still parses back to the original string, so preservation is unaffected. + expect((await readManifest(root)).note).toBe('café ☕ 😀'); + }); + + it('keys entries by root-relative forward-slash paths', async () => { + await writeSkills([skill('a')], root); + const entries = (await readManifest(root)).entries as Record; + expect(Object.keys(entries)).toEqual(['a/SKILL.md']); + }); + + it('preserves unknown top-level and per-entry fields on rewrite', async () => { + await writeManifest(root, { + manifestVersion: 1, + futureTopLevel: 'keep me', + entries: { [`a/${SKILL_MD}`]: { ...manifestEntry('a', 1, SKILL_BODY), futureEntryField: 'keep me too' } }, + }); + await mkdir(path.join(root, 'a')); + await writeFile(path.join(root, 'a', SKILL_MD), SKILL_BODY, 'utf-8'); + + await writeSkills([skill('a', 2, 'new content\n')], root); + + const manifest = await readManifest(root); + expect(manifest.futureTopLevel).toBe('keep me'); + const entries = manifest.entries as Record>; + expect(entries[`a/${SKILL_MD}`].futureEntryField).toBe('keep me too'); + expect(entries[`a/${SKILL_MD}`].version).toBe(2); + }); +}); + +// ─── Reconcile semantics ─────────────────────────────────────────────── + +describe('writeSkills reconcile semantics', () => { + it('reports skipped_current for an unchanged managed file without rewriting it', async () => { + await placeManaged(root, 'a', SKILL_BODY); + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(actionsByKey(report).a.action).toBe('skipped_current'); + expect(calls).toEqual([]); + }); + + it('reports updated when the resolved content differs', async () => { + const target = await placeManaged(root, 'a', 'old content\n'); + + const report = await writeSkills([skill('a', 2, 'new content\n')], root); + + expect(actionsByKey(report).a.action).toBe('updated'); + expect(await readFile(target, 'utf-8')).toBe('new content\n'); + }); + + it('overwrites local tampering — LD-resolved content wins', async () => { + const target = await placeManaged(root, 'a', SKILL_BODY); + await writeFile(target, 'locally tampered\n', 'utf-8'); + + const report = await writeSkills([skill('a')], root); + + expect(actionsByKey(report).a.action).toBe('updated'); + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + }); + + it('prunes a formerly-managed skill, file then empty directory', async () => { + const target = await placeManaged(root, 'gone', SKILL_BODY); + + const report = await writeSkills([], root); + + const action = actionsByKey(report).gone; + expect(action.action).toBe('removed'); + expect(action.version).toBe(1); + expect(await exists(target)).toBe(false); + expect(await exists(path.dirname(target))).toBe(false); + const entries = (await readManifest(root)).entries as Record; + expect(entries).toEqual({}); + }); + + it('prune: false keeps the file and reports no removal', async () => { + const target = await placeManaged(root, 'gone', SKILL_BODY); + + const report = await writeSkills([], root, { prune: false }); + + expect(await exists(target)).toBe(true); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + }); + + it('pruning never touches an unmanaged file inside the root', async () => { + await placeManaged(root, 'gone', SKILL_BODY); + const userFile = path.join(root, 'notes.md'); + await writeFile(userFile, 'user authored\n', 'utf-8'); + + await writeSkills([], root); + + expect(await readFile(userFile, 'utf-8')).toBe('user authored\n'); + }); + + it('keeps a skill directory that still holds other files', async () => { + const target = await placeManaged(root, 'gone', SKILL_BODY); + const sibling = path.join(path.dirname(target), 'README.md'); + await writeFile(sibling, 'user authored\n', 'utf-8'); + + await writeSkills([], root); + + expect(await exists(target)).toBe(false); + expect(await readFile(sibling, 'utf-8')).toBe('user authored\n'); + }); + + it('a prune refusal reports the version it already knows', async () => { + // The manifest entry is in hand at that point, so omitting the version + // would make a prune *failure* strictly less informative than a prune + // *success*. Here the manifest names a path this SDK could never own. + await writeManifest(root, { + manifestVersion: 1, + entries: { 'gone/nested/SKILL.md': manifestEntry('gone', 7, SKILL_BODY) }, + }); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report).gone; + expect(action.action).toBe('error'); + expect(action.version).toBe(7); + }); + + it('a prune refusal for an unlink failure reports the version too', async () => { + await placeManaged(root, 'gone', SKILL_BODY, 4); + const calls = interceptUnlink({ fail: true }); + + const report = await writeSkills([], root); + + expect(calls).toHaveLength(1); + const action = actionsByKey(report).gone; + expect(action.action).toBe('error'); + expect(action.version).toBe(4); + }); + + it('leaves version null when it genuinely is not known', async () => { + // A retrieval that failed before any content arrived: no manifest entry and + // no Skill, so there is no version to report. Do not invent one. + const report = await writeSkills([{ key: 'a', version: 3 }], root); + + const action = actionsByKey(report).a; + expect(action.action).toBe('error'); + expect(action.version).toBeNull(); + }); +}); + +// ─── Root handling ───────────────────────────────────────────────────── + +describe('writeSkills root handling', () => { + it('creates an absent leaf directory whose parent exists', async () => { + const fresh = path.join(scratch, 'fresh-root'); + + const report = await writeSkills([skill('a')], fresh); + + expect(report.ok).toBe(true); + expect(await readFile(path.join(fresh, 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('raises rather than creating missing ancestors', async () => { + await expect(writeSkills([skill('a')], path.join(scratch, 'a', 'b', 'c'))).rejects.toThrow(); + expect(await exists(path.join(scratch, 'a'))).toBe(false); + }); + + it('raises when the root is a file', async () => { + const asFile = path.join(scratch, 'a-file'); + await writeFile(asFile, 'not a directory\n', 'utf-8'); + await expect(writeSkills([skill('a')], asFile)).rejects.toThrow(); + }); + + it('a root error is not a TypeError — it is a caller value error', async () => { + // TypeError is reserved for the accessors' bare-string guard, + // and TypeError extends Error, so the negative half is what makes the + // distinction observable in TypeScript at all. + const error = await writeSkills([skill('a')], path.join(scratch, 'a', 'b')).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TypeError); + }); +}); + +// ─── Argument errors ─────────────────────────────────────────────────── + +describe('writeSkills bare-string guard', () => { + it('raises for a bare string that is not "*", naming the accepted forms', async () => { + const error = await writeSkills('pdf-extraction' as unknown as Skill[], root).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + // Unlike the accessors' guard this is a *value* error: a string is an + // accepted argument type here and only "*" is a valid one. + expect(error).not.toBeInstanceOf(TypeError); + expect((error as Error).message).toContain('"*"'); + }); + + it('"*" is accepted — the positive control', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill('a')); + _setStore(store); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(true); + expect(await exists(path.join(root, 'a', SKILL_MD))).toBe(true); + }); + + it('writes nothing before raising', async () => { + // Asserting only the raise would also pass for an implementation that + // created one directory per character before failing. + await expect(writeSkills('abc' as unknown as Skill[], root)).rejects.toThrow(); + expect(await entryNames(root)).toEqual([]); + }); + + it('rejects an out-of-range onUnavailable value', async () => { + await expect(writeSkills([skill('a')], root, { onUnavailable: 'explode' as 'keep' })).rejects.toThrow(); + }); + + it('rejects a negative timeout as a caller value error — the same class an unusable root raises', async () => { + const error = await writeSkills([skill('a')], root, { timeout: -1 }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(TypeError); + expect((error as Error).message).toContain('timeout'); + }); + + it('rejects a non-finite timeout, which would void the bound rather than shorten it', async () => { + // Both values pass the `< 0` half of the guard and make the deadline + // non-finite, so the option's whole promise — "bounds the entire call, + // including content retrieval" — goes silently away and the call runs + // unbounded. `NaN` is the worse one: `now > deadline` reads as never + // expiring while `deadline - now > 0` reads as already expired, so the same + // input can push two implementations in opposite directions. + for (const timeout of [Number.POSITIVE_INFINITY, Number.NaN]) { + const error = await writeSkills([skill('a')], root, { timeout }).catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + // The value is named as itself: `JSON.stringify` renders both of these as + // `null`, which would report a mistake the caller did not make. + expect((error as Error).message).toContain(String(timeout)); + } + // Refused before the root was touched, like every other caller value error. + expect(await entryNames(root)).toEqual([]); + }); + + it('validates the request shape before creating the root directory', async () => { + // A bare string is refused before `resolveRoot` runs, so a mistaken call + // does not leave an empty directory behind as a side effect of failing. + const absent = path.join(scratch, 'never-created'); + await expect(writeSkills('pdf-extraction' as unknown as Skill[], absent)).rejects.toThrow(/"\*"/); + expect(await exists(absent)).toBe(false); + }); +}); + +// ─── Atomicity and permissions ───────────────────────────────────────── + +describe('writeSkills atomicity and permissions', () => { + it.skipIf(process.platform === 'win32')('writes files as 0644, never executable', async () => { + await writeSkills([skill('a')], root); + const mode = (await stat(path.join(root, 'a', SKILL_MD))).mode & 0o777; + expect(mode).toBe(0o644); + expect(mode & fsConstants.S_IXUSR).toBe(0); + expect(mode & fsConstants.S_IXGRP).toBe(0); + expect(mode & fsConstants.S_IXOTH).toBe(0); + }); + + it.skipIf(process.platform === 'win32')( + 'creates per-skill directories as 0755 even under a restrictive umask', + async () => { + // mkdir's mode is masked by the umask, so without an explicit chmod a 0077 + // umask leaves // at 0700 and a separate agent identity cannot + // read the SKILL.md inside it. + const previous = process.umask(0o077); + let report: Awaited>; + try { + report = await writeSkills([skill('a')], root); + } finally { + process.umask(previous); + } + expect(report.ok).toBe(true); + expect((await stat(path.join(root, 'a'))).mode & 0o777).toBe(0o755); + expect((await stat(path.join(root, 'a', SKILL_MD))).mode & 0o777).toBe(0o644); + }, + ); + + it.skipIf(process.platform === 'win32')('keeps a setgid bit the new skill directory inherited', async (ctx) => { + // Linux copies a setgid parent's bit onto a new directory so files inside + // inherit the shared group. The explicit 0755 must not clear it; fchmod + // sets exactly the bits it is given. + await chmod(root, 0o2755); + const probe = path.join(root, 'probe'); + await mkdir(probe); + const inherits = ((await stat(probe)).mode & 0o2000) !== 0; + await rmdir(probe); + if (!inherits) ctx.skip(); + + const previous = process.umask(0o077); + let report: Awaited>; + try { + report = await writeSkills([skill('a')], root); + } finally { + process.umask(previous); + } + expect(report.ok).toBe(true); + expect((await stat(path.join(root, 'a'))).mode & 0o7777).toBe(0o2755); + }); + + it('goes through a single atomic rename with the temp file in the target directory', async () => { + // Positive control for the interception hook. Without this, + // the `calls === []` assertions in the failure tests below and in the + // traversal matrix could pass in a suite where the hook is unreachable. + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + assertAtomicRenameOf(calls, path.join(root, 'a')); + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('leaves prior content byte-for-byte intact when the rename fails', async () => { + const target = await placeManaged(root, 'a', SKILL_BODY); + const calls = interceptRename({ fail: true }); + + const report = await writeSkills([skill('a', 2, 'brand new content\n')], root); + + // The injected failure — not an unrelated rejection, and not an + // implementation that attempted nothing — is what produced the error. + assertAtomicRenameOf(calls, path.dirname(target)); + expect(report.ok).toBe(false); + const action = actionsByKey(report).a; + expect(action.action).toBe('error'); + expect(action.error).toContain(INJECTED); + + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + // No temp artifact survives the failed run. + expect(await entryNames(path.dirname(target))).toEqual([SKILL_MD]); + }); + + it('leaves no partial file and no temp file for a brand-new skill', async () => { + const calls = interceptRename({ fail: true }); + + const report = await writeSkills([skill('a')], root); + + assertAtomicRenameOf(calls, path.join(root, 'a')); + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.error).toContain(INJECTED); + expect(await exists(path.join(root, 'a', SKILL_MD))).toBe(false); + const skillDir = path.join(root, 'a'); + expect((await exists(skillDir)) ? await entryNames(skillDir) : []).toEqual([]); + }); + + it('never reuses an existing temp path — creation is exclusive', async () => { + // The temp name is unpredictable and created with O_EXCL, so two writes in a + // row use different names and neither writes through a planted file. + const first = interceptRename(); + await writeSkills([skill('a')], root); + const firstTemp = first[0].src; + vi.restoreAllMocks(); + + const second = interceptRename(); + await writeSkills([skill('a', 2, 'new content\n')], root); + + expect(second[0].src).not.toBe(firstTemp); + }); + + it('leaves a valid JSON manifest after a run containing per-skill errors', async () => { + const report = await writeSkills([skill('a'), skill('../evil')], root); + expect(report.ok).toBe(false); + expect(await readManifest(root)).toBeInstanceOf(Object); + }); +}); + +// ─── Resilience ──────────────────────────────────────────────────────── + +describe('writeSkills resilience', () => { + it('keep is the default: existing managed files survive and nothing raises', async () => { + const existing = await placeManaged(root, 'a', SKILL_BODY); + + const report = await writeSkills([{ key: 'a', version: 1 }], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + }); + + it('raise mode propagates, with a message that says retrieval was unavailable', async () => { + // Assert on the message, not just the raise — a bare "not implemented" + // error would otherwise satisfy this. + await expect(writeSkills([{ key: 'a', version: 1 }], root, { onUnavailable: 'raise' })).rejects.toThrow( + /unavailable|skill store/i, + ); + }); + + it('reports a throwing store rather than raising', async () => { + const exploding: SkillStore = { + getObject() { + throw new Error('transport failure'); + }, + allObjects() { + throw new Error('transport failure'); + }, + }; + _setStore(exploding); + + const report = await writeSkills([{ key: 'a', version: 1 }], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.error).toMatch(/transport failure/); + }); + + it('does not prune when a store outage left the run incomplete', async () => { + // Deleting managed files because a lookup failed would turn an outage into + // data loss. + const existing = await placeManaged(root, 'stays', SKILL_BODY); + _setStore({ + getObject() { + throw new Error('transport failure'); + }, + allObjects() { + throw new Error('transport failure'); + }, + }); + + const report = await writeSkills('*', root); + + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + }); + + it('does not prune when one of two references resolved and the other failed', async () => { + // The second suppression condition, and the one + // the whole-store-outage case above cannot stand in for: here the run *did* + // retrieve something, so an implementation that gated pruning on "did we get + // anything at all" passes that test and fails this one. One reference + // resolves, one does not, and a third manifest-listed skill that nobody + // asked for this run has to survive — because "revoked" and "did not arrive + // this time" are indistinguishable from here, and guessing is data loss. + const stale = path.join(root, 'stale', SKILL_MD); + await mkdir(path.dirname(stale), { recursive: true }); + await writeFile(stale, SKILL_BODY, 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + entries: { [`stale/${SKILL_MD}`]: manifestEntry('stale', 3, SKILL_BODY) }, + }); + + _setStore({ + getObject(_kind: string, key: string) { + if (key === 'resolves') return rawSkill('resolves'); + throw new Error('transport failure'); + }, + allObjects() { + return { resolves: rawSkill('resolves') }; + }, + }); + + const report = await writeSkills( + [ + { key: 'resolves', version: 1 }, + { key: 'fails', version: 1 }, + ], + root, + ); + + const byKey = actionsByKey(report); + // The run genuinely half-succeeded: one skill is on disk, the other is an + // error. That is what makes this shape different from a total outage. + expect(byKey.resolves.action).toBe('written'); + expect(await readFile(path.join(root, 'resolves', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + expect(byKey.fails.action).toBe('error'); + + // And the unrequested third skill was left alone, entry and all. + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(byKey.stale).toBeUndefined(); + expect(await readFile(stale, 'utf-8')).toBe(SKILL_BODY); + const entries = (await readManifest(root)).entries as Record; + expect(entries[`stale/${SKILL_MD}`]).toMatchObject({ key: 'stale', version: 3 }); + }); + + it('does not prune when the deadline expired partway through the writes', async () => { + // The third shape of the same rule: retrieval was fine and the manifest was + // fine, but the budget ran out before every requested skill was written — so + // the run still does not know what is current. A caller must not read "no + // `removed` actions" as "nothing is stale". + const stale = path.join(root, 'stale', SKILL_MD); + await mkdir(path.dirname(stale), { recursive: true }); + await writeFile(stale, SKILL_BODY, 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + entries: { [`stale/${SKILL_MD}`]: manifestEntry('stale', 3, SKILL_BODY) }, + }); + + // A fake monotonic clock the first rename pushes past the deadline, so the + // second skill's write is on the wrong side of it. + let nowMs = performance.now(); + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + const real = fsOps.rename.bind(fsOps); + vi.spyOn(fsOps, 'rename').mockImplementation(async (src: string, dst: string) => { + const result = await real(src, dst); + if (dst.endsWith(SKILL_MD)) nowMs += 5_000; + return result; + }); + + const report = await writeSkills([skill('first'), skill('second')], root, { timeout: 1 }); + + const byKey = actionsByKey(report); + expect(byKey.first.action).toBe('written'); + // The injected expiry is what stopped the second write, rather than some + // unrelated refusal. + expect(byKey.second.action).toBe('error'); + expect(byKey.second.error).toMatch(/timeout was exhausted/); + + // So nothing was pruned, and the stale entry survives for the next run. + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + // Not *reached*, rather than reached and refused: the prune phase was + // suppressed outright, so `stale` carries no action at all. This is the + // assertion that separates the suppression gate from the per-entry deadline + // check inside the prune loop, which would have reported + // a timeout error for the same entry and left the same file on disk. + expect(byKey.stale).toBeUndefined(); + expect(await readFile(stale, 'utf-8')).toBe(SKILL_BODY); + const entries = (await readManifest(root)).entries as Record; + expect(entries[`stale/${SKILL_MD}`]).toMatchObject({ key: 'stale', version: 3 }); + }); + + it('an exhausted timeout behaves as unavailable, and retrieves nothing', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill('a')); + // `timeout` bounds the whole call *including* retrieval: with the budget + // already spent, the store must not be consulted at all. + let lookups = 0; + _setStore( + new Proxy(store, { + get(target, property, receiver) { + if (property === 'getObject') lookups += 1; + return Reflect.get(target, property, receiver); + }, + }), + ); + const calls = interceptRename(); + + const report = await writeSkills([{ key: 'a', version: 1 }], root, { timeout: 0 }); + + expect(report.ok).toBe(false); + expect(lookups).toBe(0); + expect(calls).toEqual([]); + expect(await exists(path.join(root, 'a', SKILL_MD))).toBe(false); + }); + + it('an exhausted timeout raises in raise mode', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill('a')); + _setStore(store); + + await expect(writeSkills([{ key: 'a', version: 1 }], root, { timeout: 0, onUnavailable: 'raise' })).rejects.toThrow( + /timeout|unavailable/i, + ); + }); + + it('never prunes on a non-object listing', async () => { + // A store that cannot list is not a store holding nothing. A listing + // collapsed to "no skills" is indistinguishable from every skill having + // been revoked, and prune would then delete every managed file and report a + // clean run. The listing failure has to reach the prune gate as an + // incomplete run. + const noListing: SkillStore = { + getObject() { + return null; + }, + allObjects() { + return null as unknown as Record; + }, + }; + const existing = await placeManaged(root, 'pdf-extraction', SKILL_BODY); + _setStore(noListing); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(false); + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + expect(report.actions.map((a) => a.action)).toEqual(['error']); + expect(errorMessages(report).some((m) => m.includes('rather than an object'))).toBe(true); + // The entry survives, so the next reconcile picks it up. + expect(Object.keys((await readManifest(root)).entries as Record)).toContain( + `pdf-extraction/${SKILL_MD}`, + ); + }); + + it('writes nothing for an answer served under another key', async () => { + // The file is named after the key the object carries, so a store answering + // under a different key would write one path and prune another. Left + // unchecked, the run wrote the aliased key, then deleted it in the same + // pass because prune keys off the request — and reported ok. The requested + // key has to be the one the outcome is reported against. + const aliasing: SkillStore = { + getObject() { + return rawSkill('other-key'); + }, + allObjects() { + return {}; + }, + }; + _setStore(aliasing); + + const report = await writeSkills(['requested-key'], root); + + expect(report.ok).toBe(false); + expect(report.actions.map((a) => a.action)).toEqual(['error']); + // Reported against the key that was asked for, not the one served. + expect(actionsByKey(report)['requested-key'].action).toBe('error'); + expect(await exists(path.join(root, 'other-key'))).toBe(false); + expect((await readManifest(root)).entries).toEqual({}); + }); + + it('an answer served under another key never reaches that key’s file', async () => { + // Both keys are requested here, so nothing is prunable and the write itself + // is what is under test: unchecked, the object served under the alias is + // written to the *other* key's path, clobbering the content that key's own + // lookup resolved — and the run still reports ok. + const aliased = 'aliased\n'; + const aliasing: SkillStore = { + getObject(_kind, key) { + return key === 'other-key' ? rawSkill('other-key') : rawSkill('other-key', 2, aliased); + }, + allObjects() { + return {}; + }, + }; + const existing = await placeManaged(root, 'other-key', SKILL_BODY); + _setStore(aliasing); + + // The alias is resolved last, so an unchecked write would land on top. + const report = await writeSkills(['other-key', 'requested-key'], root); + + expect(report.ok).toBe(false); + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + expect(actionsByKey(report)['requested-key'].action).toBe('error'); + expect(actionsByKey(report)['other-key'].action).toBe('skipped_current'); + }); + + it('never corrupts the manifest on an unavailable run', async () => { + await placeManaged(root, 'a', SKILL_BODY); + const before = await readManifest(root); + + await writeSkills([{ key: 'b', version: 1 }], root); + + const after = await readManifest(root); + expect((after.entries as Record)[`a/${SKILL_MD}`]).toEqual( + (before.entries as Record)[`a/${SKILL_MD}`], + ); + }); + + it('invokes a throwing store exactly once per requested skill — no retries', async () => { + // There is deliberately no retry contract in either + // language. The number of times a throwing store is invoked is observable, + // so a retry added on one side alone would make the two diverge. + let calls = 0; + _setStore({ + getObject() { + calls += 1; + throw new Error('transport failure'); + }, + allObjects() { + return {}; + }, + }); + + await writeSkills([{ key: 'a', version: 1 }], root); + + expect(calls).toBe(1); + }); +}); + +// ─── Verify-then-write ───────────────────────────────────────────────── + +describe('writeSkills verify-then-write', () => { + it('rejects a Skill whose hash does not match its content', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const calls = interceptRename(); + const bad = createSkill({ + key: 'a', + version: 1, + content: new TextEncoder().encode('new content\n'), + contentHash: 'f'.repeat(64), + }); + + const report = await writeSkills([bad], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(calls).toEqual([]); + expect(await exists(path.join(root, 'a', SKILL_MD))).toBe(false); + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('truncates a hostile key echoed into the error to 32 characters', async () => { + // A key is attacker-reachable input, and a rejected one is echoed into + // `ReconcileAction.error`. The byte-limit branch already truncates; the + // grammar branch must too, or a 100 KB "key" becomes a 100 KB error string. + const hostile = 'A'.repeat(300); + const bad = createSkill({ + key: hostile, + version: 1, + content: new TextEncoder().encode('x'), + contentHash: hash('x'), + }); + + const report = await writeSkills([bad], root); + + expect(report.ok).toBe(false); + const message = report.errors[0].error ?? ''; + expect(message).toContain('A'.repeat(32)); + expect(message).not.toContain('A'.repeat(33)); + expect(message).toContain('...'); + }); + + it('rejects an oversize Skill', async () => { + const oversize = OVERSIZE; + const bad = createSkill({ + key: 'a', + version: 1, + content: new TextEncoder().encode(oversize), + contentHash: hash(oversize), + }); + + const report = await writeSkills([bad], root); + + expect(report.ok).toBe(false); + expect(await exists(path.join(root, 'a', SKILL_MD))).toBe(false); + }); + + it('does not disturb an existing managed file when verification fails', async () => { + const target = await placeManaged(root, 'a', SKILL_BODY); + const bad = createSkill({ + key: 'a', + version: 2, + content: new TextEncoder().encode('new content\n'), + contentHash: 'f'.repeat(64), + }); + + await writeSkills([bad], root); + + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + }); +}); + +// ─── Path traversal matrix ─────────────────────────────────────────── + +const HOSTILE_KEYS: Array<[string, string]> = [ + ['parent traversal', '../evil'], + ['dotdot', '..'], + ['dot', '.'], + ['empty', ''], + ['absolute path', '/etc/cron.d/x'], + ['backslash traversal', '..\\evil'], + ['drive letter', 'c:evil'], + ['NTFS alternate data stream', 'skill:ads'], + ['embedded null byte', 'sk\0ill'], + ['leading dash', '-skill'], + ['uppercase', 'Evil'], + ['embedded slash', 'a/b'], + ['overlong', 'x'.repeat(257)], + ['deep traversal', 'a/../../b'], + ['leading dot-slash', './a'], + ['leading space', ' leading-space'], + ['trailing space', 'trailing-space '], +]; + +describe('writeSkills path traversal', () => { + it.each(HOSTILE_KEYS)('rejects a hostile key and attempts no filesystem operation: %s', async (_label, key) => { + const outsideBefore = await entryNames(scratch); + const calls = interceptRename(); + + const report = await writeSkills([skill(key)], root); + + expect(report.ok).toBe(false); + expect(report.actions.filter((a) => a.key === key).map((a) => a.action)).toEqual(['error']); + + // The SDK's key validation — not the operating system — must be what + // stopped this. An overlong key exceeds NAME_MAX, a null byte raises in the + // path API, and an absolute path outside the root usually fails on + // permissions, so "an error was reported" is not evidence of a defense (and + // the absolute-path verdict would flip on a privileged runner). + expect(calls).toEqual([]); + + // Nothing created outside the root, and no skill directory inside it. + expect(await entryNames(scratch)).toEqual(outsideBefore); + expect((await entryNames(root)).filter((n) => n !== MANIFEST_NAME)).toEqual([]); + }); + + it('the interception hook fires for a valid key — the positive control', async () => { + const calls = interceptRename(); + + const report = await writeSkills([skill('ok-key')], root); + + expect(report.ok).toBe(true); + expect(calls.map((c) => path.basename(c.dst))).toEqual([SKILL_MD]); + }); + + it('writes a long but filesystem-legal key', async () => { + // The <= 256 length bound cannot be exercised through writeSkills: a key + // becomes a single directory name and NAME_MAX is 255 bytes on Linux and + // macOS, so the longest key the data model permits cannot exist on disk at + // all. Assert the accepting side at the largest writable length; the bound + // itself is covered by the pure layers' own tests. + const key = 'k'.repeat(255); + const report = await writeSkills([skill(key)], root); + + expect(report.ok).toBe(true); + expect(await readFile(path.join(root, key, SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('reports rather than raises for a key at the data-model bound', async () => { + // A 256-character key is valid to every pure layer but fits no filesystem, + // so it reaches writeSkills legitimately and cannot become a directory. + // Every outcome must be visible in the report, so it must + // surface as an error action rather than an exception escaping the call — + // which would also skip the manifest rewrite and orphan the file already + // written in the same run. + const calls = interceptRename(); + const longKey = 'a'.repeat(256); + + const report = await writeSkills([skill('good'), skill(longKey)], root); + + const byKey = actionsByKey(report); + expect(byKey[longKey].action).toBe('error'); + expect(byKey.good.action).toBe('written'); + expect(calls.map((c) => path.dirname(c.dst))).toEqual([path.join(root, 'good')]); + // The valid skill is fully reconciled: written AND recorded, not orphaned. + expect(await exists(path.join(root, 'good', SKILL_MD))).toBe(true); + expect(Object.keys((await readManifest(root)).entries as object)).toContain(`good/${SKILL_MD}`); + }); + + it('writes valid keys alongside rejected ones', async () => { + const report = await writeSkills([skill('good'), skill('../evil')], root); + + const byKey = actionsByKey(report); + expect(byKey.good.action).toBe('written'); + expect(byKey['../evil'].action).toBe('error'); + expect(await exists(path.join(root, 'good', SKILL_MD))).toBe(true); + }); + + it('creates nothing above the root for a traversal key', async () => { + await writeSkills([skill('../../escaped')], root); + expect(await exists(path.join(scratch, 'escaped'))).toBe(false); + expect(await exists(path.join(path.dirname(scratch), 'escaped'))).toBe(false); + }); +}); + +// ─── Symlink attacks ───────────────────────────────────────────────── + +describe('writeSkills symlink attacks', () => { + it('raises for a symlinked root and writes nothing through it', async () => { + const realDir = path.join(scratch, 'real'); + await mkdir(realDir); + const linkRoot = path.join(scratch, 'link'); + await symlink(realDir, linkRoot, 'dir'); + + await expect(writeSkills([skill('a')], linkRoot)).rejects.toThrow(); + expect(await entryNames(realDir)).toEqual([]); + }); + + it('refuses a symlinked skill directory', async () => { + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + await symlink(outside, path.join(root, 'a'), 'dir'); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(await entryNames(outside)).toEqual([]); + }); + + it('refuses a symlinked target file', async () => { + const victim = path.join(scratch, 'victim.md'); + await writeFile(victim, 'victim content\n', 'utf-8'); + await mkdir(path.join(root, 'a')); + await symlink(victim, path.join(root, 'a', SKILL_MD)); + // Manifest lists the path so clobber protection is not what saves us. + await writeManifest(root, { + manifestVersion: 1, + entries: { [`a/${SKILL_MD}`]: manifestEntry('a', 1, 'victim content\n') }, + }); + + const report = await writeSkills([skill('a', 2, 'attacker payload\n')], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + }); + + it('refuses to prune a symlinked target file', async () => { + // Asserting only that the victim survives proves nothing here: unlinking a + // symlink never touches its target, so that assertion holds for an + // implementation with no symlink check at all. The observable contract is + // the refusal itself. + const victim = path.join(scratch, 'victim.md'); + await writeFile(victim, 'victim content\n', 'utf-8'); + await mkdir(path.join(root, 'a')); + const link = path.join(root, 'a', SKILL_MD); + await symlink(victim, link); + await writeManifest(root, { + manifestVersion: 1, + entries: { [`a/${SKILL_MD}`]: manifestEntry('a', 1, 'victim content\n') }, + }); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report).a; + expect(action.action).toBe('error'); + expect(action.error).not.toBeNull(); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + // The symlink itself is left in place and stays managed. + expect((await lstat(link)).isSymbolicLink()).toBe(true); + expect(Object.keys((await readManifest(root)).entries as object)).toContain(`a/${SKILL_MD}`); + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + }); +}); + +/** + * The two symlink swap-race cases. + * + * Defensible only where a destructive operation can be addressed relative to a + * pinned directory, which is true two ways: an `*at()` family + * (`SUPPORTS_DIR_FD`, false on every Node release to date) or descriptor + * addressing through procfs (`SUPPORTS_PROC_FD`, true on Linux). Gated on the + * **same** capability probes the implementation gates on, never on a platform + * string, so a probe that silently reported "unsupported" could not also silently + * skip the tests that would have caught it. + * + * So these run on Linux, where `safe-fs.ts` addresses `/`'s children + * through `/proc/self/fd/` and the swap cannot redirect the write or the + * unlink. They are skipped on macOS and Windows, where the per-component `lstat` + * floor narrows the window without closing it and the exposure is documented + * instead — see `SUPPORTS_DIR_FD` in `safe-fs.ts`. They stay written out in full + * so they become live the moment either probe flips. + */ +describe.skipIf(!SUPPORTS_DIR_FD && !SUPPORTS_PROC_FD)('writeSkills TOCTOU swap races', () => { + /** + * Fires the swap at the exact instant of an operation: renames + * `/` aside and leaves a symlink to `outside` in its place, then + * lets the intercepted call proceed. Driven from the `fsOps` hook rather + * than from implementation internals, so the test is not coupled to how the + * defense is built. + */ + async function swapDirectory(skillDir: string, outside: string): Promise { + const movedTo = `${skillDir}.real`; + await fsOps.rename(skillDir, movedTo); + await symlink(outside, skillDir, 'dir'); + return movedTo; + } + + it('a directory swapped at the rename cannot redirect the write', async () => { + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + const skillDir = path.join(root, 'a'); + let swapped = false; + let movedTo = ''; + + const real = fsOps.rename.bind(fsOps); + vi.spyOn(fsOps, 'rename').mockImplementation(async (src: string, dst: string) => { + if (dst.endsWith(SKILL_MD) && !swapped) { + swapped = true; + movedTo = await swapDirectory(skillDir, outside); + } + return real(src, dst); + }); + + await writeSkills([skill('a')], root); + + expect(swapped).toBe(true); + expect(await entryNames(outside)).toEqual([]); + expect(await readFile(path.join(movedTo, SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('a directory swapped at the prune cannot redirect the unlink', async () => { + // `unlink` never follows a *trailing* symlink but it does resolve the + // directory above it, so an unguarded prune deletes the outside file — a + // delete primitive with an attacker-chosen target. + const outside = path.join(scratch, 'outside'); + await mkdir(outside); + const victim = path.join(outside, SKILL_MD); + await writeFile(victim, 'victim content\n', 'utf-8'); + const skillDir = path.join(root, 'gone'); + await placeManaged(root, 'gone', SKILL_BODY); + let swapped = false; + + const real = fsOps.unlink.bind(fsOps); + vi.spyOn(fsOps, 'unlink').mockImplementation(async (target: string) => { + if (target.endsWith(SKILL_MD) && !swapped) { + swapped = true; + await swapDirectory(skillDir, outside); + } + return real(target); + }); + + await writeSkills([], root); + + expect(swapped).toBe(true); + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + expect(await exists(path.join(`${skillDir}.real`, SKILL_MD))).toBe(false); + }); +}); + +describe('filesystem capability probe', () => { + it('reports descriptor addressing only where procfs actually provides it', async () => { + // The Linux fast path is gated on this, and a hardcoded `true` would make + // the swap-race cases above pass vacuously on a platform that cannot + // support them. So assert the two halves of the real property: never true + // off Linux, and on Linux only when `/proc/self/fd/` resolves back to + // the inode the descriptor holds. + if (process.platform !== 'linux') { + expect(SUPPORTS_PROC_FD).toBe(false); + return; + } + const { open, lstat, stat } = await import('node:fs/promises'); + const handle = await open(tmpdir(), fsConstants.O_RDONLY | fsConstants.O_DIRECTORY); + try { + const address = `/proc/self/fd/${handle.fd}`; + const pinned = await handle.stat({ bigint: true }); + const resolved = await stat(address, { bigint: true }); + const usable = + (await lstat(address)).isSymbolicLink() && resolved.dev === pinned.dev && resolved.ino === pinned.ino; + expect(SUPPORTS_PROC_FD).toBe(usable); + } finally { + await handle.close(); + } + }); + + it('is a real feature test rather than a hardcoded answer', async () => { + // Node exposes no `*at()` family, so TypeScript + // runs a per-component lstat check instead and the two + // swap-race cases above are skipped. If this ever fails, Node grew the + // family and the descriptor-relative path can be implemented behind the same + // probe — a welcome failure, not a regression. + const fsp = await import('node:fs/promises'); + const family = ['renameat', 'unlinkat', 'openat'] as const; + const present = family.filter((name) => typeof (fsp as unknown as Record)[name] === 'function'); + expect(SUPPORTS_DIR_FD).toBe(present.length === family.length); + }); +}); + +// ─── Clobber protection ────────────────────────────────────────────── + +describe('writeSkills clobber protection', () => { + it('never overwrites a user-placed file with no manifest entry', async () => { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, 'user authored\n', 'utf-8'); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(await readFile(target, 'utf-8')).toBe('user authored\n'); + }); + + it('never deletes a user-placed file with no manifest entry', async () => { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, 'user authored\n', 'utf-8'); + + await writeSkills([], root); + + expect(await readFile(target, 'utf-8')).toBe('user authored\n'); + }); + + it('distinguishes the adoption carve-out from an unmanaged file it must refuse', async () => { + // Adoption is the one carve-out in this row, and it does not weaken + // it: the only file ever claimed is one whose bytes *already are* the + // LaunchDarkly-resolved content. So the two have to be asserted against the + // same setup — the same root, the same absent manifest, the same + // `writeSkills` call — because byte equality is the entry condition, and an + // implementation that simply ignored the missing manifest entry would adopt + // both. This is the assertion that says the check is the bytes and not the + // absence. + const identical = path.join(root, 'same', SKILL_MD); + const divergent = path.join(root, 'differs', SKILL_MD); + await mkdir(path.dirname(identical), { recursive: true }); + await mkdir(path.dirname(divergent), { recursive: true }); + await writeFile(identical, SKILL_BODY, 'utf-8'); + // One byte longer, and otherwise the resolved content exactly. + await writeFile(divergent, `${SKILL_BODY} `, 'utf-8'); + expect(await exists(manifestPath(root))).toBe(false); + + const report = await writeSkills([skill('same'), skill('differs')], root); + + const byKey = actionsByKey(report); + // Identical bytes: adopted, and recorded as managed from now on. + expect(byKey.same.action).toBe('skipped_current'); + expect(await readFile(identical, 'utf-8')).toBe(SKILL_BODY); + const entries = (await readManifest(root)).entries as Record; + expect(entries[`same/${SKILL_MD}`]).toMatchObject({ key: 'same' }); + // One byte different: still refused, still untouched, still unclaimed. + expect(byKey.differs.action).toBe('error'); + expect(await readFile(divergent, 'utf-8')).toBe(`${SKILL_BODY} `); + expect(entries[`differs/${SKILL_MD}`]).toBeUndefined(); + }); + + it('a manifest entry with a mismatched key does not authorize destruction', async () => { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, 'user authored\n', 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + // The path is listed, but under a different key than the skill being + // written to it. + entries: { [`a/${SKILL_MD}`]: manifestEntry('some-other-key', 1, 'user authored\n') }, + }); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(await readFile(target, 'utf-8')).toBe('user authored\n'); + }); +}); + +// ─── Crash-mid-reconcile recovery ──────────────────────────────────── + +/** + * The reconcile writes every skill file and only then writes the manifest, + * atomically, once, last. A process killed in between leaves files at managed + * paths with no manifest entry — which is, to the clobber check, exactly what a + * customer-placed file looks like. Adoption on an exact hash match is what stops + * those skills being wedged forever, and it cannot weaken the guarantee, because + * the only bytes ever adopted are bytes LaunchDarkly resolved. + */ +describe('writeSkills crash-mid-reconcile recovery', () => { + /** The post-crash state: a file at a managed path, and no manifest at all. */ + async function placeOrphaned(key: string, content: string): Promise { + const target = path.join(root, key, SKILL_MD); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, content, 'utf-8'); + return target; + } + + it('adopts an unmanaged file whose bytes already are the resolved content', async () => { + const target = await placeOrphaned('a', SKILL_BODY); + expect(await exists(manifestPath(root))).toBe(false); + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + const action = actionsByKey(report).a; + expect(action.action).toBe('skipped_current'); + expect(action.path).toBe(target); + // Adoption, not a rewrite: the bytes on disk already match, so nothing is + // written and the file is left exactly as it was found. + expect(calls).toEqual([]); + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + // And the manifest now records it, which is what un-wedges the key. + const entries = (await readManifest(root)).entries as Record>; + expect(entries[`a/${SKILL_MD}`]).toMatchObject({ key: 'a', version: 1, sha256: hash(SKILL_BODY) }); + }); + + it('the reconcile after an adoption is an ordinary no-op', async () => { + await placeOrphaned('a', SKILL_BODY); + const first = await writeSkills([skill('a')], root); + expect(actionsByKey(first).a.action).toBe('skipped_current'); + + // Second run: the entry exists now, so this takes the ordinary managed path + // — nothing written, nothing removed, still ok. + const calls = interceptRename(); + const second = await writeSkills([skill('a')], root); + + expect(second.ok).toBe(true); + expect(actionsByKey(second).a.action).toBe('skipped_current'); + expect(calls).toEqual([]); + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('adopts only on an exact byte match — one byte of difference is refused', async () => { + const divergent = `${SKILL_BODY} `; + const target = await placeOrphaned('a', divergent); + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report).a; + expect(action.action).toBe('error'); + expect(action.error).toContain('does not record it as managed'); + expect(calls).toEqual([]); + expect(await readFile(target, 'utf-8')).toBe(divergent); + // Refused means refused all the way: no entry is created for it either, so + // the next run cannot mistake the file for one this SDK manages. + expect(await readManifest(root)).toMatchObject({ entries: {} }); + }); + + it('bounds the adoption comparison read at content.byteLength + 1 (§3.22)', async () => { + // The read reaches arbitrary foreign files at a managed path, so a planted + // multi-GB file must not be pulled into memory before the hash is compared. + // One byte past the resolved content's length is all that is needed to + // prove inequality. + const planted = Buffer.byteLength(SKILL_BODY, 'utf-8') + 2 * 1024 * 1024; + const target = await placeOrphaned('a', `${SKILL_BODY}${'p'.repeat(2 * 1024 * 1024)}`); + const { open } = await import('node:fs/promises'); + const probe = await open(target, 'r'); + const prototype = Object.getPrototypeOf(probe) as { read: (...args: unknown[]) => Promise<{ bytesRead: number }> }; + await probe.close(); + const consumed: number[] = []; + const realRead = prototype.read; + const spy = vi.spyOn(prototype, 'read').mockImplementation(async function (this: unknown, ...args: unknown[]) { + const result = await realRead.apply(this, args); + consumed.push(result.bytesRead); + return result; + }); + + const report = await writeSkills([skill('a')], root); + + // `mockRestore` also resets the recorded history, so read it out first. + const reads = spy.mock.calls.length; + spy.mockRestore(); + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(actionsByKey(report).a.error).toMatch(/does not record it as managed/); + expect(reads).toBeGreaterThan(0); + const total = consumed.reduce((sum, n) => sum + n, 0); + expect(total).toBeGreaterThan(0); + expect(total).toBeLessThanOrEqual(Buffer.byteLength(SKILL_BODY, 'utf-8') + 1); + // Refused and untouched. + expect((await stat(target)).size).toBe(planted); + }); + + it('an adopted file is prunable afterwards', async () => { + // Adoption is a full claim, not a one-run exemption — and this is the test + // that says so. Without it, "adopted" is indistinguishable from "tolerated + // once": an implementation that reported `skipped_current` without ever + // writing the manifest entry would pass every other case in this block, and + // then leave the file behind forever on the run that revoked it. + const target = await placeOrphaned('a', SKILL_BODY); + const first = await writeSkills([skill('a')], root); + expect(actionsByKey(first).a.action).toBe('skipped_current'); + + const second = await writeSkills([], root); + + // Removed exactly as a file this SDK wrote would have been: the action, the + // file, the now-empty directory, and the manifest entry. + expect(second.ok).toBe(true); + expect(actionsByKey(second).a.action).toBe('removed'); + expect(await exists(target)).toBe(false); + expect(await entryNames(root)).toEqual([MANIFEST_NAME]); + expect(await readManifest(root)).toMatchObject({ entries: {} }); + }); + + it('adopts one skill and refuses another in the same run', async () => { + await placeOrphaned('adopted', SKILL_BODY); + const foreign = await placeOrphaned('foreign', 'user authored\n'); + + const report = await writeSkills([skill('adopted'), skill('foreign')], root); + + const byKey = actionsByKey(report); + expect(byKey.adopted.action).toBe('skipped_current'); + expect(byKey.foreign.action).toBe('error'); + expect(await readFile(foreign, 'utf-8')).toBe('user authored\n'); + expect(Object.keys((await readManifest(root)).entries as object)).toEqual([`adopted/${SKILL_MD}`]); + }); +}); + +// ─── Targets that must not be read as ordinary files ───────────────── + +/** `chmod 000` proves nothing as root, and means something else on Windows. */ +const CAN_TEST_UNREADABLE = process.platform !== 'win32' && process.getuid?.() !== 0; + +describe('writeSkills non-regular and unreadable targets', () => { + // Adoption widened the read to unmanaged, genuinely foreign files, so the read + // itself has to be as defensive as the checks around it. A FIFO or a device + // node is neither a symlink nor a directory, so `unsafePathReason` does not + // see it at all. + it.skipIf(process.platform === 'win32')( + 'refuses a FIFO at a managed path instead of blocking on the open', + async () => { + // `readFile` on a FIFO with no writer never returns, which would hang the + // reconcile and the event loop with it. The explicit timeout is the point + // of this test: a regression has to fail here rather than stall CI. + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + execFileSync('mkfifo', [target]); + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.error).toContain('could not be read'); + expect(calls).toEqual([]); + // Left exactly as it was: a refusal is not an invitation to clean up. + expect((await lstat(target)).isFIFO()).toBe(true); + }, + 5_000, + ); + + it('refuses a directory standing where SKILL.md belongs', async () => { + // The type check is an `fstat` on the handle rather than a `stat` on the + // path, so this is refused for what the descriptor is, not what the name is. + await mkdir(path.join(root, 'a', SKILL_MD), { recursive: true }); + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.error).toContain('could not be read'); + expect(calls).toEqual([]); + expect((await lstat(path.join(root, 'a', SKILL_MD))).isDirectory()).toBe(true); + }); + + it.skipIf(!CAN_TEST_UNREADABLE)('refuses rather than overwriting when an unmanaged file cannot be read', async () => { + // The dangerous failure mode: a read error on the adoption path falling + // through to the write, which would turn "the bytes could not be checked" + // into an overwrite of a file this SDK does not own. + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, 'user authored\n', 'utf-8'); + await chmod(target, 0o000); + const calls = interceptRename(); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + const { error } = actionsByKey(report).a; + expect(error).toContain('could not be read'); + // Distinguishable from the collision refusal: "we could not look" and "we + // looked, and the bytes are foreign" call for different operator responses. + expect(error).not.toContain('does not record it as managed'); + expect(calls).toEqual([]); + + await chmod(target, 0o644); + expect(await readFile(target, 'utf-8')).toBe('user authored\n'); + }); +}); + +// ─── Windows reserved device names ─────────────────────────────────── + +/** + * Every name Windows resolves as a device rather than as a path, all of which + * the key grammar `^[a-z0-9][a-z0-9-]*$` admits. Spelled out here rather than + * imported, for the same reason the on-disk filenames are: a test that read the + * implementation's own set could not detect a change to it. + */ +const WINDOWS_RESERVED_KEYS = [ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]; + +describe('writeSkills Windows reserved device names', () => { + it('is exactly 22 names', () => { + expect(WINDOWS_RESERVED_KEYS).toHaveLength(22); + expect(new Set(WINDOWS_RESERVED_KEYS).size).toBe(22); + }); + + it.each(WINDOWS_RESERVED_KEYS)('refuses to write the reserved key %s', async (key) => { + const calls = interceptRename(); + + const report = await writeSkills([skill(key)], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report)[key]; + expect(action.action).toBe('error'); + expect(action.error).toContain('reserved device name'); + // Rejected before any filesystem call, so nothing is attempted and no + // directory is left behind on a platform that would have allowed one. + expect(calls).toEqual([]); + expect(await exists(path.join(root, key))).toBe(false); + }); + + it.each(WINDOWS_RESERVED_KEYS)('refuses to prune the reserved key %s', async (key) => { + // `keyRejectionReason` is shared by the write and the prune paths, so the + // same rejection has to hold on the destructive side: a manifest entry + // naming a key this SDK could not have written is not authority to delete. + const target = path.join(root, key, SKILL_MD); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, SKILL_BODY, 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + entries: { [`${key}/${SKILL_MD}`]: manifestEntry(key, 1, SKILL_BODY) }, + }); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report)[key]; + expect(action.action).toBe('error'); + expect(action.error).toContain('could own'); + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + // The entry is left in place rather than dropped, so the refusal stays + // visible on every run instead of silently forgetting the file. + expect(Object.keys((await readManifest(root)).entries as object)).toEqual([`${key}/${SKILL_MD}`]); + }); + + it('com0 and lpt0 are not reserved and still write', async () => { + // The set is exactly the reserved names. Widening it to a `com[0-9]` shape + // would break two keys that work fine on every platform. + const report = await writeSkills([skill('com0'), skill('lpt0')], root); + + expect(report.ok).toBe(true); + expect(await readFile(path.join(root, 'com0', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + expect(await readFile(path.join(root, 'lpt0', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('a reserved name is still a valid key at the grammar level', () => { + // This is the whole reason the check lives in the filesystem layer rather + // than in `isValidSkillKey`. `skillRefs` fails closed on a bad skills + // entry, so a grammar-level rejection would reject *every* skill reference + // for a Linux or macOS customer, over a constraint that only exists on + // Windows. A grammar that dropped the entry instead would let prune delete + // the skill's on-disk copy: "this fails to write on Windows" would become + // "this gets deleted on Linux". + for (const key of WINDOWS_RESERVED_KEYS) expect(isValidSkillKey(key)).toBe(true); + + const parsed = parseAiConfig({ + model: { name: 'gpt-4' }, + provider: { name: 'openai' }, + instructions: 'be helpful', + skills: [{ key: 'con', version: 1 }], + }); + + expect(parsed.success).toBe(true); + expect(skillRefs(parsed.success ? parsed.data : null).map((ref) => ref.key)).toEqual(['con']); + }); +}); + +// ─── Orphaned temp files ───────────────────────────────────────────── + +/** + * `atomicWrite` creates its temp file exclusively in the target's own directory + * and removes it on any error it sees — but not after a SIGKILL. Prune walks + * manifest entries only, so an orphan left that way is invisible to the rest of + * the reconcile forever, and it also blocks the `rmdir` that would clean up an + * emptied skill directory. The sweep is deliberately narrow, so most of what + * follows is about what it must *not* remove. + */ +describe('writeSkills orphaned temp files', () => { + /** Exactly the shape `safe-fs.ts` produces: `.SKILL.md.<16 lowercase hex>.tmp`. */ + function orphanName(hex = 'a1b2c3d4e5f60718'): string { + return `.${SKILL_MD}.${hex}.tmp`; + } + + it('sweeps an orphan left behind by a killed reconcile', async () => { + const target = await placeManaged(root, 'a', SKILL_BODY); + const orphan = path.join(root, 'a', orphanName()); + await writeFile(orphan, 'half-written\n', 'utf-8'); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + expect(await exists(orphan)).toBe(false); + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + }); + + it('sweeps ahead of the prune, so an orphan cannot block the rmdir', async () => { + // The orphan is why the directory would otherwise survive its own emptying: + // `rmdir` only ever succeeds on an empty directory. + await placeManaged(root, 'gone', SKILL_BODY); + await writeFile(path.join(root, 'gone', orphanName()), 'half-written\n', 'utf-8'); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(true); + expect(actionsByKey(report).gone.action).toBe('removed'); + expect(await exists(path.join(root, 'gone'))).toBe(false); + }); + + it('leaves anything that is not one of its own temp names', async () => { + await placeManaged(root, 'a', SKILL_BODY); + const keepers = [ + 'notes.md', // an ordinary customer file + `.${SKILL_MD}.tmp`, // no random component + `.${SKILL_MD}.a1b2c3d4e5f60718.tmp.bak`, // suffixed — the trailing anchor matters + `.${SKILL_MD}.NOTHEXNOTHEX0000.tmp`, // not lowercase hex + `.${SKILL_MD}.a1b2c3d4e5f607.tmp`, // fourteen hex digits, not sixteen + `.other.a1b2c3d4e5f60718.tmp`, // a temp for some other target + ]; + for (const name of keepers) await writeFile(path.join(root, 'a', name), 'keep\n', 'utf-8'); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + expect(await entryNames(path.join(root, 'a'))).toEqual([...keepers, SKILL_MD].sort()); + }); + + it('never sweeps outside a skill directory', async () => { + // The sweep only ever opens `//` for a key that passes + // `keyRejectionReason`, so a temp-shaped name in the managed root itself — + // where the manifest's own temp files live — is not its business. + const atRoot = path.join(root, orphanName()); + await writeFile(atRoot, 'not swept\n', 'utf-8'); + await placeManaged(root, 'a', SKILL_BODY); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + expect(await readFile(atRoot, 'utf-8')).toBe('not swept\n'); + }); + + it('does not unlink through a symlink named like a temp file', async () => { + // Only a regular file is removed. Unlinking the link itself would in fact be + // safe — `unlink` never follows a trailing symlink — but "only a regular + // file" is the rule that stays correct if the primitive ever changes. + const victim = path.join(scratch, 'victim.txt'); + await writeFile(victim, 'victim\n', 'utf-8'); + await placeManaged(root, 'a', SKILL_BODY); + const link = path.join(root, 'a', orphanName()); + await symlink(victim, link); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + expect(await exists(link)).toBe(true); + expect(await readFile(victim, 'utf-8')).toBe('victim\n'); + }); + + it('a corrupt manifest suppresses the sweep along with every other destructive action', async () => { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, SKILL_BODY, 'utf-8'); + const orphan = path.join(root, 'a', orphanName()); + await writeFile(orphan, 'half-written\n', 'utf-8'); + await writeManifest(root, { manifestVersion: 2, entries: {} }); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(false); + expect(await exists(orphan)).toBe(true); + }); +}); + +// ─── Corrupt manifest ──────────────────────────────────────────────── + +const DIVERGENT_CONTENT = 'existing content\n'; + +/** A parseable entries map that really does claim `a/SKILL.md` as managed. */ +function liveEntries(): Record { + return { [`a/${SKILL_MD}`]: manifestEntry('a', 1, DIVERGENT_CONTENT) }; +} + +/** + * The first six variants are unparseable: `entries` is missing, the wrong type, + * or the whole document is garbage. That makes "performed no destructive action" + * arithmetic rather than a defense — with no entries to act on, a file at a + * managed path is protected by clobber protection and there is nothing to prune, + * so those cases pass against an implementation that simply treats a corrupt + * manifest as an empty one. + * + * The `*_live_entries` variants are the ones that actually test the rule: corrupt + * ONLY in `manifestVersion`, with a valid entries map listing the managed path + * under a matching key. The implementation has everything it needs to overwrite + * and to prune, and must refuse anyway. + */ +const CORRUPT_MANIFESTS: Array<[string, unknown]> = [ + ['garbage', '{not json at all'], + ['empty', ''], + ['wrong_types', { manifestVersion: 1, entries: [`a/${SKILL_MD}`] }], + ['entries_missing', { manifestVersion: 1 }], + ['future_version', { manifestVersion: 2, entries: {} }], + ['version_not_int', { manifestVersion: '1', entries: {} }], + ['future_version_live_entries', { manifestVersion: 2, entries: liveEntries() }], + ['version_not_int_live_entries', { manifestVersion: '1', entries: liveEntries() }], + // The version gate is bounded below as well as above. No release ever + // wrote a version under 1, so these are not older schemas this release can + // still read — they are schemas that never existed. + ['version_zero_live_entries', { manifestVersion: 0, entries: liveEntries() }], + ['version_negative_live_entries', { manifestVersion: -1, entries: liveEntries() }], +]; + +const LIVE_ENTRY_MANIFESTS = CORRUPT_MANIFESTS.filter(([name]) => name.endsWith('_live_entries')); + +describe('writeSkills oversize manifest', () => { + // The manifest is a plain file in a directory the SDK does not own + // exclusively, so an unbounded read of it could exhaust the process. + it('refuses a manifest over the cap without destroying anything', async () => { + const target = await placeManaged(root, 'a', SKILL_BODY); + // Derived from the cap, so raising the bound cannot leave this test passing. + await writeManifest(root, { manifestVersion: 1, entries: {}, pad: 'x'.repeat(MAX_MANIFEST_BYTES + 1) }); + const unlinks = interceptUnlink(); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + expect(errorMessages(report).some((m) => m.includes('cap'))).toBe(true); + expect(unlinks).toEqual([]); + expect(await readFile(target, 'utf-8')).toBe(SKILL_BODY); + // Left for an operator rather than overwritten. + expect((await readManifest(root)).pad).toBeDefined(); + }); + + it('still reads a manifest exactly at the cap', async () => { + const manifest: Record = { manifestVersion: 1, entries: {} }; + const overhead = Buffer.byteLength(JSON.stringify({ ...manifest, pad: '' }), 'utf-8'); + manifest.pad = 'x'.repeat(MAX_MANIFEST_BYTES - overhead); + await writeManifest(root, manifest); + expect((await stat(manifestPath(root))).size).toBe(MAX_MANIFEST_BYTES); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(true); + expect((await readManifest(root)).pad).toBeDefined(); + }); +}); + +describe('writeSkills corrupt manifest', () => { + it.each(CORRUPT_MANIFESTS)('performs no destructive action and reports an error: %s', async (_label, raw) => { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, DIVERGENT_CONTENT, 'utf-8'); + await writeManifest(root, raw); + + const report = await writeSkills([skill('a', 2, 'new content\n')], root); + + expect(report.ok).toBe(false); + // The error must name the manifest. For the unparseable variants the file at + // the managed path is also unmanaged, so a bare "some error happened" + // assertion is satisfied by clobber protection alone and says nothing about + // whether the manifest state was detected at all. + const errors = errorMessages(report); + expect(errors.some((e) => e.toLowerCase().includes('manifest'))).toBe(true); + expect(await readFile(target, 'utf-8')).toBe(DIVERGENT_CONTENT); + }); + + it.each([0, -1])('treats manifestVersion %s as corrupt, not as an older readable schema', async (declared) => { + // Python parity: `test_manifest_version_below_one_is_corrupt`. Before the + // lower bound existed, `manifestVersion: 0` pruned a managed file and + // reported `ok` true — the destructive steps ran against entries of a + // schema that never existed. + const target = await placeManaged(root, 'a', SKILL_BODY); + await writeManifest(root, { + manifestVersion: declared, + entries: { [`a/${SKILL_MD}`]: manifestEntry('a', 1, SKILL_BODY) }, + }); + const unlinks = interceptUnlink(); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + const messages = errorMessages(report); + expect(messages.some((m) => m.includes('manifestVersion') && m.includes(MANIFEST_NAME))).toBe(true); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(unlinks).toEqual([]); + expect(await exists(target)).toBe(true); + // The manifest itself is left alone, as with any other corruption. + expect((await readManifest(root)).manifestVersion).toBe(declared); + }); + + it('a run-level error carries the empty-key sentinel', async () => { + // The empty string is public API surface: a caller grouping the report by + // key has to know the sentinel exists. + await writeManifest(root, '{not json at all'); + + const report = await writeSkills([skill('a')], root); + + const manifestErrors = report.errors.filter((a) => (a.error ?? '').toLowerCase().includes('manifest')); + expect(manifestErrors.length).toBeGreaterThan(0); + for (const action of manifestErrors) expect(action.key).toBe(''); + // A per-skill error in the same report still carries its real key, so the + // sentinel is not simply "every error action has an empty key". + for (const action of report.errors.filter((a) => !manifestErrors.includes(a))) { + expect(action.key).not.toBe(''); + } + }); + + it.each( + LIVE_ENTRY_MANIFESTS, + )('does not prune a managed file when only the version is corrupt: %s', async (_label, raw) => { + // Here the implementation can read the entries map and knows exactly which + // file it owns, so refusing to remove it is a real decision rather than an + // absence of information. + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, DIVERGENT_CONTENT, 'utf-8'); + await writeManifest(root, raw); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + expect(await readFile(target, 'utf-8')).toBe(DIVERGENT_CONTENT); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(errorMessages(report).some((e) => e.toLowerCase().includes('manifest'))).toBe(true); + }); + + it('prunes nothing under an unparseable manifest', async () => { + const target = path.join(root, 'a', SKILL_MD); + await mkdir(path.dirname(target)); + await writeFile(target, DIVERGENT_CONTENT, 'utf-8'); + await writeManifest(root, '{not json at all'); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + expect(await exists(target)).toBe(true); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + }); + + it('may still write brand-new paths', async () => { + await writeManifest(root, '{not json at all'); + + const report = await writeSkills([skill('fresh')], root); + + expect(actionsByKey(report).fresh.action).toBe('written'); + expect(await readFile(path.join(root, 'fresh', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('does not destroy the corrupt manifest file itself', async () => { + await writeManifest(root, '{not json at all'); + + await writeSkills([], root); + + expect(await readFile(manifestPath(root), 'utf-8')).toBe('{not json at all'); + }); + + it('treats an unreadable manifest as corrupt', async () => { + await placeManaged(root, 'a', DIVERGENT_CONTENT); + // Non-UTF-8 bytes are corruption, and must fail closed like any other. + await writeFile(manifestPath(root), Buffer.from([0xff, 0xfe, 0x00, 0x01])); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + expect(await exists(path.join(root, 'a', SKILL_MD))).toBe(true); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a FIFO standing in for the manifest instead of blocking on the open', + async () => { + // The manifest lives in the skills root, so it is reachable by the same + // swap the managed-path FIFO test covers. `loadManifest` also runs before + // the first deadline check, so a blocking open here would hang the + // reconcile with the `timeout` budget never consulted — and a pending + // `readFile` on a FIFO keeps the process from exiting at all. The + // explicit test timeout is the assertion: a regression has to fail here + // rather than stall CI. + await placeManaged(root, 'a', DIVERGENT_CONTENT); + await rm(manifestPath(root)); + execFileSync('mkfifo', [manifestPath(root)]); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + expect(errorMessages(report).some((e) => e.toLowerCase().includes('manifest'))).toBe(true); + // Fail closed exactly as any unreadable manifest does: nothing pruned, + // and the FIFO left where it stands rather than rewritten. + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(DIVERGENT_CONTENT); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect((await lstat(manifestPath(root))).isFIFO()).toBe(true); + }, + 5_000, + ); + + it.skipIf(process.platform === 'win32')('treats a symlinked manifest as corrupt', async () => { + // `readRegularFile` passes `O_NOFOLLOW`, so the manifest is read only from + // where the SDK itself put it. Deliberate rather than incidental: the atomic + // rewrite replaces a symlink at this path with a real file, so following one + // on read would mean trusting bytes from a location the next write silently + // orphans — and the root is writable by whoever can plant the link. + await placeManaged(root, 'a', DIVERGENT_CONTENT); + const elsewhere = path.join(scratch, 'manifest-elsewhere.json'); + await writeFile(elsewhere, await readFile(manifestPath(root), 'utf-8'), 'utf-8'); + await rm(manifestPath(root)); + await symlink(elsewhere, manifestPath(root)); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + expect(errorMessages(report).some((e) => e.toLowerCase().includes('manifest'))).toBe(true); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect((await lstat(manifestPath(root))).isSymbolicLink()).toBe(true); + }); +}); + +// ─── A well-formed manifest naming a path the SDK could not have written ───── + +/** + * The three literal cases that matter for the prune path. + * + * The distinction from the corrupt-manifest block above is the whole point: a + * corrupt manifest suppresses every destructive action wholesale, so those tests + * say nothing about these. Each manifest here is *well-formed* — parseable, a + * `manifestVersion` this release understands, a real `entries` map, and an entry + * whose `key` is a perfectly valid skill key genuinely absent from the requested + * set. The implementation has every input it needs to prune, and must refuse + * anyway, because the recorded *path* is not one this SDK could have written. + * + * The manifest is untrusted input: a plain file on the customer's disk that + * anything with write access to the managed root can edit, and `prune` is the + * one code path in the SDK that deletes. So a recorded path never authorizes its + * own removal — it must match `/SKILL.md` for a re-validated key, and the + * target is recomputed from the *current* managed root rather than read back out + * of the entry. + */ +const HOSTILE_RECORDED_PATHS: string[] = [ + // Absolute: the classic. A recorded path read back and unlinked as-is is a + // delete of an attacker-chosen file with the reconcile's own privileges. + '/etc/passwd', + // Traversing: the same attack against an implementation that rejects a + // leading slash and then joins the rest onto the managed root. + '../../../etc/passwd', +]; + +/** + * Records every prune unlink without performing it. + * + * Asserting only that `/etc/passwd` still exists proves nothing: the test + * process cannot delete it anyway, so that assertion passes against an + * implementation with no path check at all — permissions would be doing the + * work. What has teeth is that the removal is never *attempted*: the refusal + * happens above the syscall, on a path the SDK recomputes rather than trusts. + * `fsOps.unlink` is the single call site the prune deletes through. + */ +function recordUnlinks(): string[] { + const targets: string[] = []; + vi.spyOn(fsOps, 'unlink').mockImplementation(async (target: string) => { + targets.push(target); + }); + return targets; +} + +describe('writeSkills hostile manifest prune', () => { + it.each(HOSTILE_RECORDED_PATHS)('refuses a recorded path outside the root: %s', async (recorded) => { + const unlinked = recordUnlinks(); + await writeManifest(root, { + manifestVersion: 1, + entries: { [recorded]: manifestEntry('a', 1, SKILL_BODY) }, + }); + + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report).a; + expect(action.action).toBe('error'); + // The refusal is about ownership of the path, not about the file's state. + expect(action.error).toContain('could own'); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + // Nothing was even attempted, let alone completed. + expect(unlinked).toEqual([]); + expect(await exists('/etc/passwd')).toBe(true); + // Left in place rather than tidied away: dropping the entry would let a + // single hostile edit erase the SDK's own record of what it manages. + expect(await readManifest(root)).toHaveProperty(['entries', recorded]); + }); + + it('refuses an entry under a parent that has since become a symlink', async () => { + // The recorded path is the SDK's own, and is still not enough. The entry is + // exactly what a legitimate reconcile writes — `a/SKILL.md` under key `a` — + // so the shape check that catches the two cases above passes here. What + // changed is the disk underneath it. This is the case a validate-then-act + // implementation fails: the manifest and the entry are both entirely + // legitimate, and only the current state of the parent is not. + const elsewhere = path.join(scratch, 'elsewhere'); + await mkdir(elsewhere); + const victim = path.join(elsewhere, SKILL_MD); + await writeFile(victim, 'victim content\n', 'utf-8'); + + // Managed legitimately first, so the manifest entry is one this SDK really + // did write... + const managed = await placeManaged(root, 'a', SKILL_BODY); + // ...then the parent directory is swapped for a link out of the root. + await rm(managed); + await rm(path.join(root, 'a'), { recursive: true }); + await symlink(elsewhere, path.join(root, 'a'), 'dir'); + + const unlinked = recordUnlinks(); + const report = await writeSkills([], root); + + expect(report.ok).toBe(false); + const action = actionsByKey(report).a; + expect(action.action).toBe('error'); + expect(action.error).toContain('symlink'); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(unlinked).toEqual([]); + // The file the symlink pointed at is untouched, and so is the link. + expect(await readFile(victim, 'utf-8')).toBe('victim content\n'); + expect((await lstat(path.join(root, 'a'))).isSymbolicLink()).toBe(true); + expect(await readManifest(root)).toHaveProperty(['entries', `a/${SKILL_MD}`]); + }); +}); + +// ─── The prune deadline ──────────────────────────────────────────────── + +/** + * The `timeout` bounds the whole call, and the prune loop is inside it. + * + * Checking the deadline once before pruning begins satisfies that in the letter + * only: a manifest with many entries is an unbounded number of `unlink` and + * `rmdir` calls after that check, each of which can block on a slow or hostile + * filesystem, so the call overruns the budget it promised by an amount the caller + * cannot predict. The check therefore belongs inside the loop, once per entry. + * + * Stopping partway is safe by construction rather than by luck, and the second + * test is what proves it: the manifest is rewritten from what actually happened, + * so an entry this run never reached stays listed and the next reconcile prunes + * it. That is also why an exhausted entry is reported and the loop *continues* — + * the report names every skill left in place, not just the first. + * + * `performance.now` is stubbed rather than slept through: the contract is about + * which side of the deadline each entry falls on, and a test that raced a real + * clock would assert that flakily instead of exactly. + */ +describe('writeSkills prune deadline', () => { + /** A fake monotonic clock, in the same units `performance.now` reports. */ + let nowMs: number; + + /** Installs the clock and hands back a knob to push it past the deadline. */ + function stubClock(): (byMs: number) => void { + nowMs = performance.now(); + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + return (byMs: number) => { + nowMs += byMs; + }; + } + + /** Four managed skills in one manifest, all files present on disk. */ + async function placeFour(): Promise { + const keys = ['a', 'b', 'c', 'd']; + const entries: Record = {}; + for (const key of keys) { + const target = path.join(root, key, SKILL_MD); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, SKILL_BODY, 'utf-8'); + entries[`${key}/${SKILL_MD}`] = manifestEntry(key, 1, SKILL_BODY); + } + await writeManifest(root, { manifestVersion: 1, entries }); + return keys; + } + + it('stops pruning when the deadline expires partway and reports what it left', async () => { + const keys = await placeFour(); + const advance = stubClock(); + // The first unlink burns the whole budget. Every entry after it is on the + // wrong side of the deadline. + const real = fsOps.unlink.bind(fsOps); + vi.spyOn(fsOps, 'unlink').mockImplementation(async (target: string) => { + await real(target); + advance(5_000); + }); + + const report = await writeSkills([], root, { timeout: 1 }); + + // Exactly one entry was pruned — the one whose check ran before the budget + // was gone — and the rest are reported rather than silently skipped. + const removed = report.actions.filter((a) => a.action === 'removed'); + expect(removed).toHaveLength(1); + const timedOut = report.errors.filter((a) => /timeout was exhausted/.test(a.error ?? '')); + expect(timedOut).toHaveLength(keys.length - 1); + // Every key is accounted for: the loop continued instead of breaking, so the + // report names all three it left in place. + expect(new Set([...removed, ...timedOut].map((a) => a.key))).toEqual(new Set(keys)); + expect(report.ok).toBe(false); + + // The unreached files are still there... + const survivors = keys.filter((key) => key !== removed[0].key); + for (const key of survivors) { + expect(await readFile(path.join(root, key, SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + } + // ...and so are their manifest entries, which is what makes stopping safe: + // the rewrite records what happened, so the next run picks these up. + const entries = (await readManifest(root)).entries as Record; + expect(Object.keys(entries).sort()).toEqual(survivors.map((key) => `${key}/${SKILL_MD}`).sort()); + }); + + it('the next reconcile prunes what the expired one left behind', async () => { + // The other half of "stopping mid-prune is safe": the entries that survived + // are still entries, so a run with a budget finishes the job. Without this, + // the test above would also pass against an implementation that stopped and + // lost track. + await placeFour(); + const advance = stubClock(); + const real = fsOps.unlink.bind(fsOps); + const spy = vi.spyOn(fsOps, 'unlink').mockImplementation(async (target: string) => { + await real(target); + advance(5_000); + }); + + const first = await writeSkills([], root, { timeout: 1 }); + expect(first.actions.filter((a) => a.action === 'removed')).toHaveLength(1); + + // A fresh budget, and no more clock sabotage. + spy.mockRestore(); + nowMs = performance.now(); + vi.spyOn(performance, 'now').mockImplementation(() => nowMs); + + const second = await writeSkills([], root, { timeout: 10 }); + + expect(second.ok).toBe(true); + expect(second.actions.filter((a) => a.action === 'removed')).toHaveLength(3); + expect(await readManifest(root)).toMatchObject({ entries: {} }); + expect(await entryNames(root)).toEqual([MANIFEST_NAME]); + }); + + it('an already-expired deadline prunes nothing at all', async () => { + // The boundary case, and the one that says the check is not merely + // *somewhere* in the loop: with the budget gone before the first entry, the + // first entry is refused too. + const keys = await placeFour(); + const unlinked = recordUnlinks(); + + const report = await writeSkills([], root, { timeout: 0 }); + + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(report.errors.filter((a) => /timeout was exhausted/.test(a.error ?? ''))).toHaveLength(keys.length); + // Not attempted, rather than attempted and failed. + expect(unlinked).toEqual([]); + for (const key of keys) { + expect(await readFile(path.join(root, key, SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + } + const entries = (await readManifest(root)).entries as Record; + expect(Object.keys(entries).sort()).toEqual(keys.map((key) => `${key}/${SKILL_MD}`).sort()); + }); +}); + +// ─── Telemetry seam (write half) ─────────────────────────────────────── + +describe('writeSkills telemetry', () => { + it('records one Materialized signal per written, updated, and skipped_current', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + for (const [key, content] of [ + ['same', SKILL_BODY], + ['stale', 'old\n'], + ] as const) { + await mkdir(path.join(root, key)); + await writeFile(path.join(root, key, SKILL_MD), content, 'utf-8'); + } + await writeManifest(root, { + manifestVersion: 1, + entries: { + [`same/${SKILL_MD}`]: manifestEntry('same', 1, SKILL_BODY), + [`stale/${SKILL_MD}`]: manifestEntry('stale', 1, 'old\n'), + }, + }); + + await writeSkills([skill('same'), skill('stale', 2, 'fresh\n'), skill('brand-new')], root); + + const materialized = emitter.signals(MATERIALIZED_SIGNAL); + expect(materialized).toHaveLength(3); + expect(materialized.map((p) => p.reconcile_action).sort()).toEqual(['skipped_current', 'updated', 'written']); + }); + + it('records the Materialized signal with the exact property keys', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + await writeSkills([skill('a', 2)], root); + + const [props] = emitter.signals(MATERIALIZED_SIGNAL); + expect(Object.keys(props).sort()).toEqual([ + 'content_bytes', + 'content_hash', + 'language', + 'reconcile_action', + 'skill_key', + ]); + expect(props.skill_key).toBe('a'); + expect(props.content_bytes).toBe(Buffer.byteLength(SKILL_BODY, 'utf-8')); + expect(props.content_hash).toBe(hash(SKILL_BODY)); + expect(props.reconcile_action).toBe('written'); + expect(props.language).toBe('typescript'); + }); + + it('puts no filesystem path in telemetry', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + await writeSkills([skill('a')], root); + + for (const [, props] of emitter.records) { + expect(props).not.toHaveProperty('target_path'); + for (const value of Object.values(props)) { + expect(String(value)).not.toContain(root); + expect(String(value)).not.toContain(SKILL_MD); + } + } + }); + + it('puts no skill body in telemetry', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + await writeSkills([skill('a')], root); + + for (const [, props] of emitter.records) { + for (const value of Object.values(props)) { + expect(String(value)).not.toContain('Do the thing.'); + } + } + }); + + it('records the Revoked signal on a prune', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + await placeManaged(root, 'gone', SKILL_BODY, 3); + + await writeSkills([], root); + + const [props] = emitter.signals(REVOKED_SIGNAL); + expect(props.skill_key).toBe('gone'); + expect(props.version).toBe(3); + expect(props.removed_from_disk).toBe(true); + expect(props.language).toBe('typescript'); + }); + + it('omits version from the Revoked signal when the manifest entry is malformed', async () => { + // The absent half of the bullet above, and the one that needs asserting: + // `version` is *omitted*, not recorded as null. The manifest is a file on + // disk, so the value in hand here is whatever was written there — a caller + // reads "no usable version was recorded" off the key's absence rather than + // off a null, and the signal never carries a null for a field that was not + // known. `toHaveProperty` is the assertion that distinguishes the two; + // reading `props.version` and comparing to undefined would pass either way. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + await mkdir(path.join(root, 'gone'), { recursive: true }); + await writeFile(path.join(root, 'gone', SKILL_MD), SKILL_BODY, 'utf-8'); + await writeManifest(root, { + manifestVersion: 1, + entries: { + [`gone/${SKILL_MD}`]: { + key: 'gone', + version: 'seven', + sha256: hash(SKILL_BODY), + writtenAt: '2026-08-14T19:00:00Z', + }, + }, + }); + + await writeSkills([], root); + + const [props] = emitter.signals(REVOKED_SIGNAL); + expect(props).toBeDefined(); + expect(props.skill_key).toBe('gone'); + expect(props.removed_from_disk).toBe(true); + expect(props).not.toHaveProperty('version'); + expect(Object.keys(props).sort()).toEqual(['language', 'removed_from_disk', 'skill_key']); + }); + + it('publishes no skill body when a manifest entry carries the body as its key', async () => { + // The manifest is as attacker-controlled as a wire object — anything with + // write access to the root can author it — so the key it carries gets the + // same shape check the wire key does before it reaches a signal. + // + // Note where this is actually stopped today: `pruneEntries` rejects an entry + // whose key fails validation before `pruneOne` runs, so the run reports a + // prune `error` and no Revoked signal is emitted at all. Either way the body + // stays out of telemetry, which is what this asserts. The redaction inside + // `recordRevoked` is the second line of defense behind that guard, and it is + // pinned directly below — an end-to-end test cannot reach it. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const hostileKey = SKILL_BODY; + await writeManifest(root, { + manifestVersion: 1, + entries: { + [`${hostileKey}/${SKILL_MD}`]: { + key: hostileKey, + version: 1, + sha256: hash(SKILL_BODY), + writtenAt: '2026-08-14T19:00:00Z', + }, + }, + }); + + const report = await writeSkills([], root); + + for (const [, props] of emitter.records) { + for (const value of Object.values(props)) { + expect(String(value)).not.toContain('Do the thing.'); + } + } + // The entry is reported rather than silently ignored: a manifest path this + // SDK could not have written is left in place and surfaced as an error. + expect(report.ok).toBe(false); + expect(emitter.signals(REVOKED_SIGNAL)).toEqual([]); + }); + + it('redacts a manifest-supplied key that is not a valid skill key', async () => { + // Called directly because the prune path's own key validation refuses the + // entry first (see the test above), so this branch is unreachable end to + // end. It is still the parity requirement and the defense that holds if that + // guard is ever relaxed — and it asserts the placeholder's *effect*, the + // body being absent, rather than its exact spelling. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + recordRevoked(SKILL_BODY, 3); + + const [props] = emitter.signals(REVOKED_SIGNAL); + expect(props.skill_key).not.toContain('Do the thing.'); + expect(isValidSkillKey(props.skill_key)).toBe(false); + expect(props.version).toBe(3); + expect(props.removed_from_disk).toBe(true); + }); + + it('records no Revoked signal when pruning is disabled', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + await placeManaged(root, 'gone', SKILL_BODY); + + await writeSkills([], root, { prune: false }); + + expect(emitter.signals(REVOKED_SIGNAL)).toEqual([]); + }); + + it('records no signal outside the approved set across all four actions', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + for (const [key, content] of [ + ['same', SKILL_BODY], + ['stale', 'old\n'], + ['gone', 'g\n'], + ] as const) { + await mkdir(path.join(root, key)); + await writeFile(path.join(root, key, SKILL_MD), content, 'utf-8'); + } + await writeManifest(root, { + manifestVersion: 1, + entries: { + [`same/${SKILL_MD}`]: manifestEntry('same', 1, SKILL_BODY), + [`stale/${SKILL_MD}`]: manifestEntry('stale', 1, 'old\n'), + [`gone/${SKILL_MD}`]: manifestEntry('gone', 1, 'g\n'), + }, + }); + + const report = await writeSkills([skill('same'), skill('stale', 2, 'fresh\n'), skill('brand-new')], root); + + // Positive control: the subset assertion is vacuous unless the run really + // did produce all four actions and record for them. + expect(new Set(report.actions.map((a) => a.action))).toEqual( + new Set(['skipped_current', 'updated', 'written', 'removed']), + ); + const recorded = emitter.names(); + expect([...recorded].filter((name) => !APPROVED_SIGNALS.has(name))).toEqual([]); + for (const removed of REMOVED_SIGNALS) expect(recorded.has(removed)).toBe(false); + expect(recorded).toEqual(new Set([MATERIALIZED_SIGNAL, REVOKED_SIGNAL])); + }); + + it('a throwing emitter never breaks the reconcile', async () => { + _setEmitterForTesting({ + record() { + throw new Error('emitter exploded'); + }, + }); + + const report = await writeSkills([skill('a')], root); + + expect(report.ok).toBe(true); + expect(await readFile(path.join(root, 'a', SKILL_MD), 'utf-8')).toBe(SKILL_BODY); + }); + + it('records the same integrity property keys whichever layer caught the failure', async () => { + // Verification runs twice by design: once at the accessor boundary and again + // immediately before a write. `expected_hash` is an optional property, + // so an implementation that populates it on one path and omits it on the + // other passes every other assertion in this block while making the signal's + // shape depend on which internal code path noticed. Oversize content is the + // case reachable from both layers with the expected hash in hand throughout. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const oversize = OVERSIZE; + const contentHash = hash(oversize); + + // Layer 1 — the accessor boundary. + const store = new InMemorySkillStore(); + store.put({ key: 'big', version: 1, content: oversize, contentHash }); + _setStore(store); + const { getSkill } = await import('../skills.js'); + expect(await getSkill('big')).toBeNull(); + + // Layer 2 — verify-then-write, on a directly constructed Skill. + const report = await writeSkills( + [createSkill({ key: 'big', version: 1, content: new TextEncoder().encode(oversize), contentHash })], + root, + ); + expect(report.ok).toBe(false); + + const failures = emitter.signals(INTEGRITY_SIGNAL); + expect(failures).toHaveLength(2); + const [accessorKeys, writeKeys] = failures.map((props) => Object.keys(props).sort()); + expect(accessorKeys).toEqual(writeKeys); + expect(accessorKeys).toContain('expected_hash'); + }); +}); + +// ─── Readiness: a store that has not heard yet must not authorize a prune ──── + +/** + * `isInitialized()` is the optional readiness half of the store seam, and + * pruning is suppressed whenever the run cannot tell what is still current. + * + * Through `allObjects` there is no difference between "this environment holds + * no skills" and "delivery has not answered yet": both are an empty result. + * `writeSkills('*')` reads the first as every skill having been revoked, so + * without the probe a reconcile racing a slow boot deletes every managed file + * and reports success. Mirrors Python's `TestUninitializedStore`. + */ +describe('writeSkills uninitialized store', () => { + /** A delivery store whose first payload has not arrived. */ + function waiting(initialized: boolean): SkillStore & { isInitialized(): boolean } { + return { + isInitialized: () => initialized, + getObject: () => null, + allObjects: () => ({}), + }; + } + + it('"*" does not prune before the first payload', async () => { + const existing = await placeManaged(root, 'a', SKILL_BODY); + const unlinks = interceptUnlink(); + _setStore(waiting(false)); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(false); + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(unlinks).toEqual([]); + // The entry survives, so a later reconcile still knows it owns the file. + expect((await readManifest(root)).entries).toHaveProperty(`a/${SKILL_MD}`); + // The error is run-level, is worded as a retrieval-unavailable failure, and + // names the remedy: wait for delivery. + const messages = errorMessages(report); + expect(messages.some((m) => m.includes('initial data'))).toBe(true); + expect(messages.some((m) => m.includes('skill retrieval unavailable'))).toBe(true); + expect(messages.some((m) => m.includes('waitForSkills'))).toBe(true); + expect(report.errors.every((a) => a.key === '')).toBe(true); + }); + + it('a reference resolve against an uninitialized store is unavailable too, and suppresses prune', async () => { + // The check lives in the single gate both forms go through, so the + // reference form reports the same unavailability and holds prune off the + // unrelated managed entry. + const stale = await placeManaged(root, 'stale', SKILL_BODY); + _setStore(waiting(false)); + + const report = await writeSkills([{ key: 'a', version: 1 }], root); + + expect(report.ok).toBe(false); + expect(actionsByKey(report).a.action).toBe('error'); + expect(actionsByKey(report).a.error).toContain('initial data'); + expect(actionsByKey(report).stale).toBeUndefined(); + expect(await readFile(stale, 'utf-8')).toBe(SKILL_BODY); + }); + + it('"*" throws in raise mode before the first payload', async () => { + _setStore(waiting(false)); + await expect(writeSkills('*', root, { onUnavailable: 'raise' })).rejects.toThrow(/initial data/); + }); + + it('an initialized store prunes as before — the positive control', async () => { + await placeManaged(root, 'a', SKILL_BODY); + _setStore(waiting(true)); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(true); + expect(actionsByKey(report).a.action).toBe('removed'); + expect(await exists(path.join(root, 'a'))).toBe(false); + }); + + it('a store without the probe is treated as initialized', async () => { + // `isInitialized` is optional; absent means initialized. A hand-populated + // store is never waiting for anything, so requiring the probe would break + // every InMemorySkillStore caller. + const store = new InMemorySkillStore(); + expect('isInitialized' in store).toBe(false); + _setStore(store); + await placeManaged(root, 'a', SKILL_BODY); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(true); + expect(actionsByKey(report).a.action).toBe('removed'); + }); + + it('a probe that throws counts as uninitialized, and warns', async () => { + // A store that cannot say whether it is ready does not get to delete. + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const existing = await placeManaged(root, 'a', SKILL_BODY); + _setStore({ + isInitialized: () => { + throw new Error('cannot tell'); + }, + getObject: () => null, + allObjects: () => ({}), + }); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(false); + expect(await exists(existing)).toBe(true); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(warnSpy.mock.calls.map((c) => String(c[0])).join('\n')).toMatch(/isInitialized/); + }); +}); + +// ─── The "*" form's own hole: a withholding with no key to protect anything ── + +describe('writeSkills unattributed withholding', () => { + it('an unattributable failure never prunes (§3.22: the run is reported incomplete)', async () => { + // Every per-object failure the wildcard resolve can attribute to a key + // keeps that key in the requested set, which is what holds prune off the + // copy on disk. When neither the object's own `key` nor the store's map key + // is usable the failure is run-level, so there is no key to hold the + // on-disk copy with — reporting the run incomplete is the only thing left + // that stops prune reading an unreadable object as a revocation. + const seed = new InMemorySkillStore(); + seed.put(rawSkill('pdf-extraction')); + seed.put(rawSkill('other')); + _setStore(seed); + const first = await writeSkills('*', root); + expect(first.ok).toBe(true); + const existing = path.join(root, 'pdf-extraction', SKILL_MD); + + // The shipped store keys `allObjects` as `:`, which is never a + // valid skill key, so a mangled `key` field leaves no fallback. The good + // skill is still served alongside it. + const mangled: RawSkillObject = { + key: 'PDF Extraction!', + version: 1, + content: SKILL_BODY, + contentHash: hash(SKILL_BODY), + }; + _setStore({ + getObject: () => null, + allObjects: () => ({ 'other:1': rawSkill('other'), 'pdf-extraction:1': mangled }), + }); + const unlinks = interceptUnlink(); + + const report = await writeSkills('*', root); + + expect(report.ok).toBe(false); + expect(report.errors.some((a) => a.key === '' && /invalid key/.test(a.error ?? ''))).toBe(true); + expect(report.actions.filter((a) => a.action === 'removed')).toEqual([]); + expect(unlinks).toEqual([]); + expect(await readFile(existing, 'utf-8')).toBe(SKILL_BODY); + expect((await readManifest(root)).entries).toHaveProperty(`pdf-extraction/${SKILL_MD}`); + // The good skill still reconciled — the run is incomplete, not abandoned. + expect(actionsByKey(report).other.action).toBe('skipped_current'); + }); +}); + +// ─── Cleanup guard ─────────────────────────────────────────────────────────── + +describe('test hygiene', () => { + it('restores the intercepted filesystem operations between tests', async () => { + // `vi.restoreAllMocks` in afterEach must put the real functions back, or a + // later test would silently run against a spy from an earlier one. + await chmod(root, 0o755); + expect(vi.isMockFunction(fsOps.rename)).toBe(false); + expect(vi.isMockFunction(fsOps.unlink)).toBe(false); + }); +}); diff --git a/packages/client/src/__tests__/skills.test.ts b/packages/client/src/__tests__/skills.test.ts new file mode 100644 index 00000000..961b4393 --- /dev/null +++ b/packages/client/src/__tests__/skills.test.ts @@ -0,0 +1,3227 @@ +/** + * Agent Skills — value types, reference discovery, the `SkillStore` seam, the + * content accessors, the accessor half of the telemetry seam, and the local + * integrity-failure log record. + * + * No network, no real LaunchDarkly client, no real skill transport. + */ + +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── Mock the optional OTel peer deps so the BYOC initClient path is inert ──── + +vi.mock('@opentelemetry/sdk-trace-node', () => ({ + NodeTracerProvider: class { + shutdown = vi.fn().mockResolvedValue(undefined); + }, +})); +vi.mock('@opentelemetry/sdk-trace-base', () => ({ BatchSpanProcessor: class {} })); +vi.mock('@opentelemetry/exporter-trace-otlp-http', () => ({ OTLPTraceExporter: class {} })); +vi.mock('@opentelemetry/otlp-exporter-base', () => ({ CompressionAlgorithm: { GZIP: 'gzip' } })); +vi.mock('@opentelemetry/resources', () => ({ resourceFromAttributes: () => ({}) })); +vi.mock('@opentelemetry/context-async-hooks', () => ({ + AsyncLocalStorageContextManager: class { + enable = vi.fn(); + disable = vi.fn(); + }, +})); +vi.mock('@opentelemetry/core', () => ({ + CompositePropagator: class {}, + W3CBaggagePropagator: class {}, + W3CTraceContextPropagator: class {}, +})); +vi.mock('@opentelemetry/api', () => ({ + createContextKey: (name: string) => Symbol(name), + trace: { getTracerProvider: () => ({ _delegate: {} }), setGlobalTracerProvider: () => true, disable: vi.fn() }, + context: { setGlobalContextManager: () => true, disable: vi.fn() }, + propagation: { setGlobalPropagator: () => true, disable: vi.fn() }, +})); +vi.mock('dotenv/config', () => ({})); + +const { mockLdInit } = vi.hoisted(() => ({ mockLdInit: vi.fn() })); +vi.mock('@launchdarkly/node-server-sdk', () => ({ init: (...args: unknown[]) => mockLdInit(...args) })); + +import * as experimental from '../experimental.js'; +import * as packageIndex from '../index.js'; +import { initClient, shutdown } from '../lifecycle.js'; +import { + _clearState, + _setEmitterForTesting, + _setStore, + allSkills, + getSkill, + getSkillResult, + getSkills, + InMemorySkillStore, + setSkillStore, + skillRefs, +} from '../skills.js'; +import { allRawObjects, clearState, MAX_SKILL_CONTENT_BYTES, requireStore, SKILL_OBJECT_KIND } from '../skills-core.js'; +import type { RawSkillObject, Skill, SkillStore } from '../types.js'; +import { + createReconcileAction, + createReconcileReport, + createSkill, + createSkillOutcome, + createSkillReference, +} from '../types.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +// The wire delivers `content` as a JSON string; a verified `Skill` carries the +// encoded bytes. Tests need both forms. +const SKILL_BODY = '---\nname: Test Skill\n---\nDo the thing.\n'; +const SKILL_BODY_BYTES = new TextEncoder().encode(SKILL_BODY); + +const INTEGRITY_SIGNAL = 'AgentControl Skill Integrity Failure'; +const MATERIALIZED_SIGNAL = 'AgentControl Skill Materialized'; +const REVOKED_SIGNAL = 'AgentControl Skill Revoked Received'; + +/** The three signal names are an allowlist, not a floor. */ +const APPROVED_SIGNALS = new Set([INTEGRITY_SIGNAL, MATERIALIZED_SIGNAL, REVOKED_SIGNAL]); + +/** + * Signal names that must never be emitted by the SDK — named explicitly so the + * regression is unmissable. + */ +const REMOVED_SIGNALS = ['AgentControl Skill SDK Reference Returned', 'AgentControl Skill Content Retrieved']; + +/** + * Convenience for building fixtures whose `contentHash` is correct. + * + * Deliberately the same expression the implementation hashes with, which is + * what makes it useless as an oracle: a change to the hashing rule moves every + * fixture built here along with it, and nothing in this file would fail. The + * rule is pinned independently, against the digests LaunchDarkly's delivery + * service computes, in the `contentHash contract` suite below — whose literal digests must + * never be replaced by a call to this helper. + */ +function hash(content: string | Uint8Array): string { + return createHash('sha256') + .update(typeof content === 'string' ? Buffer.from(content, 'utf-8') : content) + .digest('hex'); +} + +/** A wire-shaped raw store object with a correct `contentHash`. */ +function rawSkill(overrides: Partial & { key?: unknown } = {}): RawSkillObject { + const content = typeof overrides.content === 'string' ? overrides.content : SKILL_BODY; + return { + key: 'test-skill', + version: 1, + content, + contentHash: hash(content), + name: 'Test Skill', + description: 'A skill used in tests.', + ...overrides, + } as RawSkillObject; +} + +/** + * One over-cap string for the whole file. + * + * At 10 MiB this costs real time and memory to allocate and to hash, so the cases + * that need a true over-cap value share this one. + */ +const OVERSIZE = 'x'.repeat(MAX_SKILL_CONTENT_BYTES + 1); + +function skill(content: Uint8Array = SKILL_BODY_BYTES, key = 'test-skill', version = 1): Skill { + return createSkill({ key, version, content, contentHash: hash(content) }); +} + +class RecordingEmitter { + records: Array<[string, Record]> = []; + record(signal: string, properties: Record): void { + this.records.push([signal, properties]); + } + signals(name: string): Array> { + return this.records.filter(([s]) => s === name).map(([, p]) => p); + } + names(): Set { + return new Set(this.records.map(([s]) => s)); + } +} + +class ThrowingEmitter { + record(): void { + throw new Error('emitter exploded'); + } +} + +/** A minimal store that serves exactly what it was handed, unvalidated. */ +class DictStore implements SkillStore { + constructor(private readonly objects: Record) {} + getObject(_kind: string, key: string): RawSkillObject | null { + return this.objects[key] ?? null; + } + allObjects(): Record { + return { ...this.objects }; + } +} + +function makeMockLdClient() { + return { + track: vi.fn(), + flush: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + variation: vi.fn().mockResolvedValue(null), + }; +} + +function clearClientSingleton() { + const key = Symbol.for('@launchdarkly/ai-server:singleton'); + (globalThis as Record)[key] = null; +} + +beforeEach(() => { + mockLdInit.mockReset(); + _clearState(); + clearClientSingleton(); + delete process.env.LD_SDK_KEY; +}); + +afterEach(() => { + _clearState(); + clearClientSingleton(); + delete process.env.LD_SDK_KEY; +}); + +// ─── Skill types ─────────────────────────────────────────────────────── + +describe('skill value types', () => { + it('SkillReference is immutable', () => { + const ref = createSkillReference({ key: 'a', version: 1 }); + expect(() => { + (ref as { key: string }).key = 'b'; + }).toThrow(TypeError); + expect(ref.key).toBe('a'); + }); + + it('Skill is immutable', () => { + const s = skill(); + expect(() => { + (s as { content: Uint8Array }).content = new TextEncoder().encode('tampered'); + }).toThrow(TypeError); + expect(s.content).toBe(SKILL_BODY_BYTES); + }); + + it('Skill carries optional metadata', () => { + const s = createSkill({ + key: 'a', + version: 2, + content: SKILL_BODY_BYTES, + contentHash: hash(SKILL_BODY_BYTES), + name: 'PDF Extraction', + description: 'Extracts text.', + }); + expect(s.name).toBe('PDF Extraction'); + expect(s.description).toBe('Extracts text.'); + }); + + it('Skill metadata defaults to null', () => { + const s = skill(); + expect(s.name).toBeNull(); + expect(s.description).toBeNull(); + }); + + it('ReconcileAction is immutable and defaults its optional fields to null', () => { + const action = createReconcileAction({ key: 'a', action: 'written' }); + expect(action.version).toBeNull(); + expect(action.path).toBeNull(); + expect(action.error).toBeNull(); + expect(() => { + (action as { action: string }).action = 'error'; + }).toThrow(TypeError); + }); +}); + +describe('ReconcileReport ok and errors', () => { + const nonError = (key: string, action: 'written' | 'updated' | 'skipped_current' | 'removed') => + createReconcileAction({ key, action }); + const errored = (key: string) => createReconcileAction({ key, action: 'error', error: `${key} failed` }); + + it('ok is true when no action is an error', () => { + const report = createReconcileReport([ + nonError('a', 'written'), + nonError('b', 'updated'), + nonError('c', 'skipped_current'), + nonError('d', 'removed'), + ]); + expect(report.ok).toBe(true); + }); + + it('ok is false when at least one action is an error', () => { + const report = createReconcileReport([nonError('a', 'written'), errored('b')]); + expect(report.ok).toBe(false); + }); + + it('an empty report is ok', () => { + expect(createReconcileReport([]).ok).toBe(true); + }); + + it('errors lists the error actions in actions order', () => { + const first = errored('first'); + const second = errored('second'); + const report = createReconcileReport([ + nonError('a', 'written'), + first, + nonError('b', 'updated'), + second, + nonError('c', 'removed'), + ]); + expect(report.errors).toEqual([first, second]); + }); + + it('errors is empty when no action is an error', () => { + const report = createReconcileReport([nonError('a', 'written')]); + expect(report.errors).toEqual([]); + }); + + it('ok and errors always agree', () => { + for (const actions of [ + [], + [nonError('a', 'written')], + [errored('a')], + [nonError('a', 'written'), errored('b'), nonError('c', 'removed')], + ]) { + const report = createReconcileReport(actions); + expect(report.ok).toBe(report.errors.length === 0); + } + }); +}); + +// ─── Package exports ─────────────────────────────────────────────────── + +/** + * The experimental entry point's runtime exports for Agent Skills, exactly. + * + * An allowlist rather than a denylist: anything added to the entry point — + * including a reader over `Skill.content` that nobody thought to deny — fails + * the equality below until it is added here on purpose. + */ +const EXPERIMENTAL_SKILLS_VALUES = [ + 'FDv2SkillStore', + 'InMemorySkillStore', + 'MANIFEST_FILENAME', + 'MANIFEST_VERSION', + 'SKILLS_DEFAULT_BASE_URI', + 'SKILLS_DEFAULT_DEBOUNCE_MS', + 'SKILLS_DEFAULT_STREAM_URI', + 'SKILL_FILENAME', + 'SkillWatcher', + 'allSkills', + 'createSkill', + 'createSkillOutcome', + 'createSkillReference', + 'getSkill', + 'getSkillResult', + 'getSkills', + 'setSkillStore', + 'skillRefs', + 'watchSkills', + 'writeSkills', +]; + +/** The experimental entry point's type-only exports for Agent Skills. */ +const EXPERIMENTAL_SKILLS_TYPES = [ + 'FDv2Mode', + 'FDv2SkillStoreOptions', + 'OnUnavailable', + 'RawSkillObject', + 'ReconcileAction', + 'ReconcileActionKind', + 'ReconcileReport', + 'Skill', + 'SkillOutcome', + 'SkillOutcomeReason', + 'SkillReference', + 'SkillStore', + 'StoreDiagnostics', + 'WatchSkillsOptions', + 'WriteSkillsOptions', +]; + +/** + * Every runtime value the package root exports. A snapshot, so a new root + * export (a `frontmatter` reader, a leaked Skills name) fails here rather than + * depending on someone reading the API report diff. Update it deliberately, + * in the change that adds or removes a root export. + */ +const ROOT_VALUES = [ + 'ConversationIdSpanProcessor', + 'GraphTopologySchema', + 'NATIVE_TOOL_KEY', + 'NativeTool', + 'Registry', + 'addCachedTokensToInput', + 'anyMultimodal', + 'buildJudgeTasks', + 'camelizeModelParameters', + 'collapseMessagesToInstructions', + 'compose', + 'composeHistory', + 'config', + 'configInternal', + 'contentToText', + 'createHandler', + 'createHandlerInternal', + 'createRunUsage', + 'endSpanOnce', + 'getClient', + 'globalRegistry', + 'graph', + 'graphInternal', + 'hasMultimodalContent', + 'imageBlockToUrl', + 'initClient', + 'inspectConfig', + 'inspectConfigInternal', + 'isContentBlocks', + 'langChainContentText', + 'langChainFinishReasons', + 'langChainSpanMessages', + 'langChainSpanUsage', + 'makeNodeTrackData', + 'makeRunTrackData', + 'normalizeModelParameters', + 'omitModelStamps', + 'parseJSONWithPossibleFences', + 'parseTemplate', + 'pickForwardedModelParameters', + 'registerAiSdkPackage', + 'reportUsage', + 'resolveGraph', + 'runJudge', + 'setConversationIdIfAbsent', + 'setInputContentAttributes', + 'setLdSpanAttributes', + 'setModelIdentityAttributes', + 'setOutputContentAttributes', + 'setToolCallContentAttributes', + 'setToolDefinitionAttributes', + 'setUsageSpanAttributes', + 'shutdown', + 'shutdownTelemetry', + 'textMessage', + 'toSemconvFinishReason', + 'waitForTelemetry', + 'withConversationId', +]; + +describe('package exports', () => { + it('the experimental entry point exports exactly the documented skills values', () => { + expect(Object.keys(experimental).sort()).toEqual([...EXPERIMENTAL_SKILLS_VALUES].sort()); + }); + + it('the package root exports exactly the snapshotted values', () => { + expect(Object.keys(packageIndex).sort()).toEqual([...ROOT_VALUES].sort()); + }); + + it('no skills value is exported from the package root', () => { + for (const name of EXPERIMENTAL_SKILLS_VALUES) { + expect(name in packageIndex, name).toBe(false); + } + }); + + it('no skills type is named by the package root API report', () => { + // Types leave nothing to enumerate at runtime, so the checked-in API + // Extractor report for the root is what pins their absence there. A core + // type that mentioned one (e.g. `AiConfigRep.skills: SkillReference[]`) + // would also show up here. + const report = readFileSync(new URL('../../etc/ai-server.api.md', import.meta.url), 'utf-8'); + for (const name of EXPERIMENTAL_SKILLS_TYPES) { + expect(report, name).not.toMatch(new RegExp(`\\b${name}\\b`)); + } + const experimentalReport = readFileSync( + new URL('../../etc/ai-server-experimental.api.md', import.meta.url), + 'utf-8', + ); + for (const name of [...EXPERIMENTAL_SKILLS_TYPES, ...EXPERIMENTAL_SKILLS_VALUES]) { + expect(experimentalReport, name).toMatch(new RegExp(`\\b${name}\\b`)); + } + }); + + it('initClient options do not name the skill store', () => { + // Core never names experimental (§0.3): the store has its own setter. + const report = readFileSync(new URL('../../etc/ai-server.api.md', import.meta.url), 'utf-8'); + expect(report).not.toMatch(/skillStore/); + expect(report).not.toMatch(/setSkillStore/); + }); + + it('exports the three fixed values from the experimental entry point with exact values', () => { + // These three are API, not implementation detail: a caller needs + // MANIFEST_FILENAME to gitignore the manifest, and all three describe an + // on-disk layout this SDK defines and a caller may have to agree with. + expect(experimental.SKILL_FILENAME).toBe('SKILL.md'); + expect(experimental.MANIFEST_FILENAME).toBe('.launchdarkly-skills.json'); + expect(experimental.MANIFEST_VERSION).toBe(1); + }); + + it('does not export the object kind or the content cap from either entry point', () => { + // The absence is itself the contract, so it gets an assertion — an + // accidental re-export is caught here rather than shipping. + // + // SKILL_OBJECT_KIND is an SDK-side seam string rather than the wire format: + // it is what the accessors hand SkillStore.getObject, and an adapter is free + // to map it onto whatever its transport actually uses. MAX_SKILL_CONTENT_BYTES + // is a local enforcement bound on content the platform produces, set well + // above the platform's own limit precisely so that limit can move without + // this constant following — a caller pre-flighting "will my skill fit?" + // against it would be reading the backstop, not the real bound. + for (const surface of [packageIndex, experimental]) { + expect('SKILL_OBJECT_KIND' in surface).toBe(false); + expect('MAX_SKILL_CONTENT_BYTES' in surface).toBe(false); + } + + // Still reachable through the implementation module, for the store + // implementer who has to agree with them. Asserted here rather than left + // implicit so the absence above reads as "not published" rather than "gone". + expect(SKILL_OBJECT_KIND).toBe('skill'); + expect(MAX_SKILL_CONTENT_BYTES).toBe(10 * 1024 * 1024); + }); + + it('exports the skills functions and the in-memory store from the experimental entry point', () => { + expect(typeof experimental.skillRefs).toBe('function'); + expect(typeof experimental.getSkill).toBe('function'); + expect(typeof experimental.getSkills).toBe('function'); + expect(typeof experimental.allSkills).toBe('function'); + expect(typeof experimental.writeSkills).toBe('function'); + expect(typeof experimental.createSkill).toBe('function'); + expect(typeof experimental.createSkillReference).toBe('function'); + expect(typeof experimental.InMemorySkillStore).toBe('function'); + expect(typeof experimental.setSkillStore).toBe('function'); + }); + + it('the ReconcileActionKind union admits exactly the five action strings', () => { + // The record is exhaustive over the union, but `yarn typecheck` covers only + // `src/*.ts`, so nothing compiles this file: the guard against a sixth + // member is `api:check`, which diffs the union in the experimental API + // report. This test pins the runtime spelling of the five strings. + const exhaustive: Record = { + written: true, + updated: true, + skipped_current: true, + removed: true, + error: true, + }; + expect(Object.keys(exhaustive).sort()).toEqual(['error', 'removed', 'skipped_current', 'updated', 'written']); + }); + + it('the OnUnavailable union admits exactly keep and raise', () => { + const exhaustive: Record = { keep: true, raise: true }; + expect(Object.keys(exhaustive).sort()).toEqual(['keep', 'raise']); + }); + + it('exports the delivery transport, the watcher, and their defaults from the experimental entry point', () => { + // The transport and the watcher are experimental exports in both languages; + // the base-URI and debounce defaults are TypeScript-only. + expect(typeof experimental.FDv2SkillStore).toBe('function'); + expect(typeof experimental.watchSkills).toBe('function'); + expect(typeof experimental.SkillWatcher).toBe('function'); + expect(experimental.SKILLS_DEFAULT_BASE_URI).toBe('https://sdk.launchdarkly.com'); + expect(experimental.SKILLS_DEFAULT_STREAM_URI).toBe('https://stream.launchdarkly.com'); + expect(experimental.SKILLS_DEFAULT_DEBOUNCE_MS).toBe(500); + }); + + it('exports getSkillResult and the outcome factory from the experimental entry point', () => { + expect(typeof experimental.getSkillResult).toBe('function'); + expect(typeof experimental.createSkillOutcome).toBe('function'); + }); + + it('the SkillOutcomeReason union admits exactly the five reason tokens', () => { + // The five tokens are API — customers branch on them, and every SDK + // publishes the same five for the same conditions. Adding a sixth here + // should force a matching change in the other SDKs, not just a green test. + // + // Named through the experimental entry point rather than through types.js: + // it is the published export that is fixed, and a union reachable only from + // the implementation module is not reachable by a supported import. + const exhaustive: Record = { + absent: true, + integrity_failure: true, + ok: true, + store_unavailable: true, + wrong_version: true, + }; + expect(Object.keys(exhaustive).sort()).toEqual([ + 'absent', + 'integrity_failure', + 'ok', + 'store_unavailable', + 'wrong_version', + ]); + }); +}); + +// ─── skillRefs ───────────────────────────────────────────────────────── + +describe('skillRefs', () => { + const base = { model: { name: 'gpt-4o' }, provider: { name: 'OpenAI' }, instructions: 'hi' }; + + it('returns an empty list when skills is absent', () => { + expect(skillRefs(base)).toEqual([]); + }); + + it('returns an empty list when skills is empty', () => { + expect(skillRefs({ ...base, skills: [] })).toEqual([]); + }); + + it('returns typed references preserving input order', () => { + const refs = skillRefs({ + ...base, + skills: [ + { key: 'a', version: 1 }, + { key: 'b', version: 3 }, + ], + }); + expect(refs).toEqual([ + { key: 'a', version: 1 }, + { key: 'b', version: 3 }, + ]); + }); + + it.each([ + ['null', null], + ['undefined', undefined], + ['a string', 'doc-agent'], + ['a number', 3], + ['a boolean', false], + ['an array', [{ key: 'pdf-extraction', version: 1 }]], + ])('throws for a config that is not an object: %s', (_label, config) => { + // `inspectConfig` answers `config: null` on any failure. Read as "no + // skills", `writeSkills(skillRefs(info.config), root)` would prune every + // skill it manages during an outage, under its default `prune: true`. + expect(() => skillRefs(config as never)).toThrow(TypeError); + expect(() => skillRefs(config as never)).toThrow(/resolved AI Config/); + }); + + it('throws for the config a failed inspectConfig returns, so writeSkills never sees an empty list', async () => { + const mockClient = makeMockLdClient(); + mockClient.variation.mockRejectedValue(new Error('LaunchDarkly unreachable')); + await initClient(mockClient); + + const { inspectConfig } = await import('../lifecycle.js'); + const result = await inspectConfig('doc-agent', { kind: 'user', key: 'u1' }); + + expect(result.config).toBeNull(); + expect(() => skillRefs(result.config)).toThrow(TypeError); + }); + + it.each([ + ['null', null], + ['a string', 'pdf-extraction'], + ['an object', { key: 'pdf-extraction', version: 1 }], + ['a number', 3], + ['a bare string entry', ['pdf-extraction']], + ['a null entry', [null]], + ['a nested array entry', [['pdf-extraction', 1]]], + ['an absent key', [{ version: 1 }]], + ['a numeric key', [{ key: 7, version: 1 }]], + ['a null key', [{ key: null, version: 1 }]], + ['an uppercase key', [{ key: 'Evil', version: 1 }]], + ['a leading-dash key', [{ key: '-skill', version: 1 }]], + ['a leading-dot key', [{ key: '.hidden', version: 1 }]], + ['a path-separator key', [{ key: 'a/b', version: 1 }]], + ['a backslash key', [{ key: 'a\\b', version: 1 }]], + ['a traversal key', [{ key: '../evil', version: 1 }]], + ['an empty key', [{ key: '', version: 1 }]], + ['a key with a space', [{ key: 'has space', version: 1 }]], + ['an underscore key', [{ key: 'has_underscore', version: 1 }]], + ['a key with a trailing newline', [{ key: 'pdf-extraction\n', version: 1 }]], + ['a 257-character key', [{ key: 'a'.repeat(257), version: 1 }]], + ['an absent version', [{ key: 'a' }]], + ['version 0', [{ key: 'a', version: 0 }]], + ['a negative version', [{ key: 'a', version: -1 }]], + ['a non-integer version', [{ key: 'a', version: 2.5 }]], + ['a string version', [{ key: 'a', version: '2' }]], + ['a boolean version', [{ key: 'a', version: true }]], + ['a null version', [{ key: 'a', version: null }]], + ['a NaN version', [{ key: 'a', version: Number.NaN }]], + ['an Infinity version', [{ key: 'a', version: Number.POSITIVE_INFINITY }]], + ])('throws for a malformed skills field: %s', (_label, skills) => { + // `parseAiConfig` passes `skills` through, so it is validated here. + // Returning `[]` or a shortened list instead would let `writeSkills` with + // `prune: true` (the default) delete skills the config still references. + // `skills: null` is included: read as "no skills" it would prune everything. + expect(() => skillRefs({ ...base, skills })).toThrow(TypeError); + expect(() => skillRefs({ ...base, skills })).toThrow(/skills/); + }); + + it('rejects the whole field when one entry among several is invalid', () => { + const skills = [ + { key: 'good-one', version: 1 }, + { key: 'BAD', version: 1 }, + { key: 'good-two', version: 2 }, + ]; + expect(() => skillRefs({ ...base, skills })).toThrow(/skills\[1\]\.key/); + }); + + it('does not echo the rejected key in the error', () => { + expect(() => skillRefs({ ...base, skills: [{ key: 'Secret-Name', version: 1 }] })).toThrow( + expect.objectContaining({ message: expect.not.stringContaining('Secret-Name') }), + ); + }); + + it('accepts a key of exactly 256 characters and version 1', () => { + // The accepting side of the <= 256 bound. It is only observable at the pure + // layers: a key becomes one directory name and NAME_MAX is 255, so + // `writeSkills` can never reach it. + const key = 'a'.repeat(256); + expect(skillRefs({ ...base, skills: [{ key, version: 1 }] })).toEqual([{ key, version: 1 }]); + }); + + it('warns about nothing when every entry is usable', async () => { + // The positive control: a suite in which the warning never fires at all + // cannot tell a per-drop warning from an unconditional one. + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(skillRefs({ ...base, skills: [{ key: 'a', version: 1 }] })).toHaveLength(1); + expect(spy.mock.calls).toEqual([]); + } finally { + spy.mockRestore(); + } + }); + + it('emits no telemetry', () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + skillRefs({ ...base, skills: [{ key: 'a', version: 1 }] }); + expect(emitter.records).toEqual([]); + }); + + it('needs no client and no configured store', () => { + // No initClient, no _setStore — a pure projection must work regardless. + expect(skillRefs({ ...base, skills: [{ key: 'a', version: 1 }] })).toHaveLength(1); + }); + + it('rides through inspectConfig without changing its signature', async () => { + const mockClient = makeMockLdClient(); + const variation = { + ...base, + skills: [{ key: 'pdf-extraction', version: 2 }], + _ldMeta: { enabled: true, variationKey: 'v1', version: 1 }, + }; + mockClient.variation.mockResolvedValue(variation); + await initClient(mockClient); + + const { inspectConfig } = await import('../lifecycle.js'); + const result = await inspectConfig('doc-agent', { kind: 'user', key: 'u1' }); + + expect(result.enabled).toBe(true); + expect(skillRefs(result.config)).toEqual([{ key: 'pdf-extraction', version: 2 }]); + }); +}); + +// ─── InMemorySkillStore ──────────────────────────────────────────────── + +describe('InMemorySkillStore', () => { + it('round-trips objects handed to the constructor', () => { + const raw = rawSkill({ key: 'a' }); + const store = new InMemorySkillStore({ a: raw }); + expect(store.getObject('skill', 'a')).toEqual(raw); + }); + + it('returns null for an unknown key', () => { + expect(new InMemorySkillStore().getObject('skill', 'nope')).toBeNull(); + }); + + it('implements no isInitialized probe — a hand-populated store is never waiting (§3.21)', () => { + expect('isInitialized' in new InMemorySkillStore()).toBe(false); + }); + + it('put then get', () => { + const store = new InMemorySkillStore(); + const raw = rawSkill({ key: 'a' }); + store.put(raw); + expect(store.getObject('skill', 'a')).toEqual(raw); + }); + + it("allObjects returns everything held, identified by each object's own key", () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + store.put(rawSkill({ key: 'b' })); + + const held = Object.values(store.allObjects('skill')); + + // Deliberately not `Object.keys`: the record's keys are opaque + // store-internal identifiers, and `SkillStore` says identity is read off + // each object's own `key` and `version`. A test that asserted the spelling + // would be pinning an implementation detail the interface disclaims. + expect(held).toHaveLength(2); + expect(held.map((raw) => raw.key).sort()).toEqual(['a', 'b']); + }); + + it('allObjects returns one entry per (key, version), not per key', () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 2 })); + store.put(rawSkill({ key: 'a', version: 5 })); + + const held = Object.values(store.allObjects('skill')); + + expect(held).toHaveLength(2); + expect(held.map((raw) => raw.version).sort()).toEqual([2, 5]); + }); + + it('replaces an object put twice under the same (key, version)', () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 2, content: 'first' })); + store.put(rawSkill({ key: 'a', version: 2, content: 'second' })); + + const held = Object.values(store.allObjects('skill')); + + expect(held).toHaveLength(1); + expect(held[0].content).toBe('second'); + }); + + it('getObject with no version answers with the newest version held', () => { + const store = new InMemorySkillStore(); + // Put the newer one first, so insertion order cannot pass for ordering. + store.put(rawSkill({ key: 'a', version: 5 })); + store.put(rawSkill({ key: 'a', version: 2 })); + + expect(store.getObject('skill', 'a')?.version).toBe(5); + expect(store.getObject('skill', 'a', null)?.version).toBe(5); + }); + + it('getObject with a version answers with exactly that version', () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 2 })); + store.put(rawSkill({ key: 'a', version: 5 })); + + expect(store.getObject('skill', 'a', 2)?.version).toBe(2); + expect(store.getObject('skill', 'a', 5)?.version).toBe(5); + }); + + it('getObject returns null for a pin that misses while well-formed versions exist', () => { + // A plain miss. Answering it with some other version the store happens to + // hold would record an integrity failure for a skill whose integrity is not + // in question. + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 2 })); + store.put(rawSkill({ key: 'a', version: 5 })); + + expect(store.getObject('skill', 'a', 3)).toBeNull(); + }); + + it('serves a malformed object for a pinned request when nothing well-formed is held', () => { + // An object too malformed to carry a usable version is still served, so + // verification is what withholds it — with a signal. Reading it as simply + // absent would let a tampered object look like a deleted one, and would let + // a prune delete the last known-good copy on disk. + const store = new InMemorySkillStore(); + const malformed = rawSkill({ key: 'a', version: 'not-a-version' as unknown as number }); + store.put(malformed); + + expect(store.getObject('skill', 'a', 4)).toBe(malformed); + expect(store.getObject('skill', 'a')).toBe(malformed); + expect(Object.values(store.allObjects('skill'))).toEqual([malformed]); + }); + + it('cannot be reached through the prototype', () => { + // `constructor` satisfies the skill-key pattern, so it arrives as an + // ordinary lookup. Backed by a plain object, an empty store would answer it + // with the `Object` function. Asserted on the store's own answer rather than + // an accessor's: verification already refuses a function as a non-object, so + // this is about the store's semantics being right, not a content-integrity + // hole. + const empty = new InMemorySkillStore(); + expect(empty.getObject('skill', 'constructor')).toBeNull(); + expect(empty.getObject('skill', '__proto__')).toBeNull(); + expect(empty.getObject('skill', 'toString')).toBeNull(); + expect(Object.values(empty.allObjects('skill'))).toEqual([]); + }); + + it('cannot be corrupted by a put of an inherited name', () => { + // Backed by a plain object, this `put` writes the *prototype*, and every + // later lookup whose key happens to name a field of the planted object — + // `name` and `description` both satisfy the skill-key pattern — answers with + // that field's value instead of null. + const store = new InMemorySkillStore(); + const planted = rawSkill({ key: '__proto__', version: 1 }); + store.put(planted); + + expect(store.getObject('skill', 'name')).toBeNull(); + expect(store.getObject('skill', 'description')).toBeNull(); + expect(store.getObject('skill', 'content')).toBeNull(); + + store.put(rawSkill({ key: 'a', version: 1 })); + expect(store.getObject('skill', 'a')?.key).toBe('a'); + + // And the planted object is held under its own key rather than lost, so the + // listing does not silently shrink. + expect(store.getObject('skill', '__proto__')).toBe(planted); + expect(Object.values(store.allObjects('skill'))).toHaveLength(2); + }); + + it('cannot be corrupted by an inherited name in the constructor map', () => { + const planted = rawSkill({ key: '__proto__', version: 1 }); + const store = new InMemorySkillStore({ ['__proto__']: planted }); + + expect(store.getObject('skill', 'name')).toBeNull(); + expect(store.getObject('skill', 'description')).toBeNull(); + expect(store.getObject('skill', '__proto__')).toBe(planted); + }); + + it('returns nothing for a kind other than skill', () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + expect(store.getObject('flag', 'a')).toBeNull(); + expect(store.allObjects('flag')).toEqual({}); + }); + + it('notifies skill-kind listeners on put, verbatim and unverified', () => { + const store = new InMemorySkillStore(); + const seen: RawSkillObject[] = []; + store.addListener('skill', (raw) => seen.push(raw)); + // Deliberately hash-invalid: the store performs no validation, so the + // callback must see exactly what was stored. + const raw = rawSkill({ key: 'a', contentHash: 'deadbeef' }); + store.put(raw); + expect(seen).toEqual([raw]); + expect(seen[0]).toBe(raw); + }); + + it('refuses a listener for a kind it cannot notify', () => { + const store = new InMemorySkillStore(); + expect(() => store.addListener('flag', vi.fn())).toThrow(/never fire/); + }); + + it('keeps no listener it refused', () => { + const store = new InMemorySkillStore(); + const other = vi.fn(); + expect(() => store.addListener('flag', other)).toThrow(); + store.put(rawSkill({ key: 'a' })); + expect(other).not.toHaveBeenCalled(); + }); + + it('stops notifying a listener once removed', () => { + const store = new InMemorySkillStore(); + const seen = vi.fn(); + store.addListener('skill', seen); + store.removeListener('skill', seen); + store.put(rawSkill({ key: 'a' })); + expect(seen).not.toHaveBeenCalled(); + }); + + it('removes one occurrence per removeListener call', () => { + const store = new InMemorySkillStore(); + const seen = vi.fn(); + store.addListener('skill', seen); + store.addListener('skill', seen); + store.removeListener('skill', seen); + store.put(rawSkill({ key: 'a' })); + expect(seen).toHaveBeenCalledTimes(1); + }); + + it('treats removing an unregistered listener as a no-op', () => { + const store = new InMemorySkillStore(); + const fn = vi.fn(); + store.removeListener('skill', fn); + store.addListener('skill', fn); + store.removeListener('flag', fn); + store.removeListener('skill', fn); + store.removeListener('skill', fn); + store.put(rawSkill({ key: 'a' })); + expect(fn).not.toHaveBeenCalled(); + }); + + it('rejects a raw object with no string key', () => { + expect(() => new InMemorySkillStore().put({ version: 1 } as RawSkillObject)).toThrow(); + }); +}); + +// ─── Store configuration ─────────────────────────────────────────────── + +describe('store configuration', () => { + it('is configured via setSkillStore', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + setSkillStore(store); + + const found = await getSkill('a'); + expect(found?.key).toBe('a'); + }); + + it('is not configured by an initClient option', async () => { + // Core client options never name an experimental type (§0.3), so a + // `skillStore` smuggled into the options bag must not be read. + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + await initClient(makeMockLdClient(), { skillStore: store } as never); + // The options overload returns early once the client exists, but still warns. + await initClient({ skillStore: store } as unknown as Parameters[0]); + + await expect(getSkill('a')).rejects.toThrow(/skill store/i); + // ...and reports the ignored key generically, without core naming the + // experimental feature or its setter. + expect(warn.mock.calls).toEqual([ + ['[LaunchDarkly] Ignoring unrecognized initClient option(s): skillStore'], + ['[LaunchDarkly] Ignoring unrecognized initClient option(s): skillStore'], + ]); + } finally { + warn.mockRestore(); + } + }); + + it('getSkill raises actionably when no store is configured', async () => { + await expect(getSkill('a')).rejects.toThrow(/skill store/i); + }); + + it('getSkills raises actionably when no store is configured', async () => { + await expect(getSkills([{ key: 'a', version: 1 }])).rejects.toThrow(/skill store/i); + }); + + it('allSkills raises actionably when no store is configured', async () => { + await expect(allSkills()).rejects.toThrow(/skill store/i); + }); + + it('the no-store message says what to configure', async () => { + // Assert the message content, not just the raise: a bare "not implemented" + // would otherwise satisfy the test. + await expect(getSkill('a')).rejects.toThrow(/setSkillStore/); + await expect(getSkill('a')).rejects.toThrow(/@launchdarkly\/ai-server\/experimental/); + await expect(getSkill('a')).rejects.toThrow(/InMemorySkillStore/); + await expect(getSkill('a')).rejects.not.toThrow(/initClient/); + }); + + it('the no-store message names the delivery store first', async () => { + // A deployment that hits this message must be pointed at the store that + // receives content from LaunchDarkly, not only at the development one. + const message = await getSkill('a').then( + () => '', + (cause: Error) => cause.message, + ); + expect(message).toContain('FDv2SkillStore'); + expect(message).toContain('InMemorySkillStore'); + expect(message.indexOf('FDv2SkillStore')).toBeLessThan(message.indexOf('InMemorySkillStore')); + expect(message).not.toContain('follow-up release'); + }); + + it('shutdown clears the store', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + await initClient(makeMockLdClient()); + setSkillStore(store); + expect(await getSkill('a')).not.toBeNull(); + + await shutdown(); + + await expect(getSkill('a')).rejects.toThrow(/skill store/i); + }); + + it('shutdown clears a store configured without a client', async () => { + setSkillStore(new InMemorySkillStore()); + + await shutdown(); + + await expect(getSkill('a')).rejects.toThrow(/skill store/i); + }); + + it('applies on every call, after the client exists, without re-initializing it', async () => { + // The setter does not go through initClient, so a lazily initialized + // client can be given a store afterwards, and a second call replaces the + // first store. Both halves are asserted on the same calls; each is + // meaningless alone. + process.env.LD_SDK_KEY = 'sdk-test'; + const sdkClient = makeMockLdClient(); + mockLdInit.mockReturnValue({ ...sdkClient, waitForInitialization: vi.fn().mockResolvedValue(undefined) }); + + const first = new InMemorySkillStore(); + first.put(rawSkill({ key: 'first' })); + const second = new InMemorySkillStore(); + second.put(rawSkill({ key: 'second' })); + + const client = await initClient(); + setSkillStore(first); + expect(await getSkill('first')).not.toBeNull(); + setSkillStore(second); + + // Half one: the client singleton is unchanged and the SDK init ran once. + const { getClient } = await import('../lifecycle.js'); + expect(getClient()).toBe(client); + expect(mockLdInit).toHaveBeenCalledTimes(1); + + // Half two: the store was nevertheless swapped. + expect(await getSkill('second')).not.toBeNull(); + expect(await getSkill('first')).toBeNull(); + }); + + it('a nullish argument leaves the configured store alone', async () => { + // Otherwise an unrelated code path passing an unset variable would silently + // unconfigure skills; only shutdown() clears the store. + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + setSkillStore(store); + + setSkillStore(null); + setSkillStore(undefined); + + expect(await getSkill('a')).not.toBeNull(); + }); + + it.each([ + ['a string', 'x'], + ['an empty object', {}], + ['a number', 0], + ['an object with only getObject', { getObject: () => null }], + ['an object with only allObjects', { allObjects: () => [] }], + ])('rejects %s, leaving the configured store alone', async (_label, notAStore) => { + // A value without the store methods fails where it is passed, not as a + // store_unavailable on the first accessor call (matches Python). + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + setSkillStore(store); + + expect(() => setSkillStore(notAStore as never)).toThrow(TypeError); + expect(() => setSkillStore(notAStore as never)).toThrow(/SkillStore/); + + expect(await getSkill('a')).not.toBeNull(); + }); + + it('registers its state cleanup as a shutdown hook', () => { + // Core's shutdown() reaches this state only through the hook registry + // (lifecycle.test.ts covers the registry's isolation of a throwing hook). + const hooks = (globalThis as Record)[Symbol.for('@launchdarkly/ai-server:shutdown-hooks')]; + expect(hooks).toBeInstanceOf(Map); + expect((hooks as Map).get('experimental Agent Skills')).toBe(clearState); + }); + + it('the test-state reset clears the store and the emitter', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + _setStore(store); + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + expect(await getSkill('a')).not.toBeNull(); + + _clearState(); + + await expect(getSkill('a')).rejects.toThrow(/skill store/i); + // The emitter went with it: re-configuring a store and triggering a failure + // records nothing on the old emitter. + _setStore(new DictStore({ bad: rawSkill({ key: 'bad', contentHash: '0'.repeat(64) }) })); + expect(await getSkill('bad')).toBeNull(); + expect(emitter.records).toEqual([]); + }); +}); + +// ─── Accessor argument errors ────────────────────────────────────────── + +describe('getSkills bare-string guard', () => { + it('raises a TypeError, naming the fix', async () => { + _setStore(new InMemorySkillStore()); + // A string is *never* a valid argument here; iterating one would look up a + // skill per character. Deliberately a different error class from + // writeSkills's bare-string rejection, where '*' is a valid string argument. + const error = await getSkills('pdf-extraction' as unknown as string[]).catch((e: unknown) => e); + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).message).toContain('[key]'); + }); + + it('rejects before the store is consulted', async () => { + // Asserting the raise alone would also pass if the string were iterated + // into single-character lookups that all missed, so pin that no lookup + // happened at all. + const lookedUp: string[] = []; + _setStore({ + getObject(_kind: string, key: string) { + lookedUp.push(key); + return null; + }, + allObjects() { + return {}; + }, + }); + + await expect(getSkills('abc' as unknown as string[])).rejects.toThrow(TypeError); + expect(lookedUp).toEqual([]); + }); +}); + +// ─── getSkill ────────────────────────────────────────────────────────── + +describe('getSkill', () => { + let store: InMemorySkillStore; + + beforeEach(() => { + store = new InMemorySkillStore(); + _setStore(store); + }); + + it('returns the verified skill with verbatim content bytes and metadata', async () => { + store.put(rawSkill({ key: 'pdf-extraction', version: 2 })); + const found = await getSkill('pdf-extraction'); + expect(found).not.toBeNull(); + expect(found?.key).toBe('pdf-extraction'); + expect(found?.version).toBe(2); + // The declared type, asserted before the value: `content` is an opaque byte + // buffer and never a string, and `toEqual` alone passes for any + // structurally-equal value — including a plain array of the same numbers. + expect(found?.content).toBeInstanceOf(Uint8Array); + expect(found?.content).toEqual(SKILL_BODY_BYTES); + expect(found?.contentHash).toBe(hash(SKILL_BODY)); + expect(found?.name).toBe('Test Skill'); + expect(found?.description).toBe('A skill used in tests.'); + }); + + it('an omitted version means the newest available', async () => { + // Two versions, newer one seeded first: a store holding one version cannot + // distinguish "newest" from "the only one", and insertion order must not + // pass for ordering. + store.put(rawSkill({ key: 'a', version: 7 })); + store.put(rawSkill({ key: 'a', version: 4 })); + expect((await getSkill('a'))?.version).toBe(7); + }); + + it('matches an exact requested version', async () => { + store.put(rawSkill({ key: 'a', version: 3 })); + expect((await getSkill('a', { version: 3 }))?.version).toBe(3); + }); + + it('returns null for a version the store does not hold', async () => { + store.put(rawSkill({ key: 'a', version: 3 })); + expect(await getSkill('a', { version: 2 })).toBeNull(); + expect(await getSkill('a', { version: 4 })).toBeNull(); + }); + + it('returns null for a missing key, never raising', async () => { + expect(await getSkill('nope')).toBeNull(); + }); + + it('withholds a store answering under a different key as integrity_failure', async () => { + // The key needs the same post-fetch defense the version already has. + // Identity is read off the object itself, and the store is untrusted. An + // answer served under a different key would otherwise be handed back under + // the key the caller asked for while carrying its own. + // + // The token is `integrity_failure`, not `wrong_version`: content was + // delivered and its identity did not verify, which is the one outcome a + // caller is expected to fail closed on. `wrong_version` names a version + // mismatch specifically and there is deliberately no `wrong_key`, so a + // substituting store filed under it would be invisible to a caller + // branching on the token. Pinned here because the choice is not recoverable + // from the message. + const aliasing: SkillStore = { + getObject() { + return rawSkill({ key: 'other-key' }); + }, + allObjects() { + return {}; + }, + }; + _setStore(aliasing); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let collapsed: Skill | null; + let outcome: Awaited>; + try { + collapsed = await getSkill('asked-for'); + outcome = await getSkillResult('asked-for'); + } finally { + spy.mockRestore(); + } + + expect(collapsed).toBeNull(); + expect(outcome.skill).toBeNull(); + expect(outcome.reason).toBe('integrity_failure'); + // Branch on the token, not on the message: `detail` is for a human. Assert + // only that it is present and carries no skill body. + expect(outcome.detail).toBeTruthy(); + expect(outcome.detail).not.toContain('Do the thing.'); + }); + + it('records the key mismatch on the log surface but not the product signal', async () => { + // The asymmetry is the contract, and it is one-directional, so it is + // asserted in both directions here. + // + // The log record fires because a substituting store is a genuine tampering + // indicator and the record is the customer-owned detection path — the only + // one that works with telemetry off. Reusing the `ld.skills.integrity_failure` + // event identity is deliberate: a customer's existing SIEM rule catches this + // case without being rewritten, and `reason_code` is what distinguishes it. + // + // The product signal stays out of it because the overwhelmingly common cause + // of a key mismatch is not an attacker but a broken store adapter — a stale + // cache entry, a colliding key, a wrong index lookup — and LaunchDarkly's + // own counter must not fill up with customers' adapter bugs. That is the + // same false positive the pinned-non-object path refuses for the same reason. + const aliasing: SkillStore = { + getObject() { + return rawSkill({ key: 'served-key' }); + }, + allObjects() { + return {}; + }, + }; + _setStore(aliasing); + + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let errorCalls: unknown[][] = []; + try { + await getSkill('asked-for'); + } finally { + // Read the calls out before restoring: `mockRestore` also resets the + // recorded history, so a read afterwards sees nothing. + errorCalls = [...spy.mock.calls]; + spy.mockRestore(); + } + + // The signal surface saw nothing at all — not merely no integrity signal. + expect(emitter.records).toEqual([]); + + const lines = errorCalls.filter(([first]) => String(first).includes('ld.skills.integrity_failure')); + expect(lines).toHaveLength(1); + const [first, ...rest] = lines[0]; + const line = String(first); + const record = JSON.parse(line.slice(line.indexOf('{'))) as Record; + + expect(record.reason_code).toBe('key_mismatch'); + expect(record.event).toBe('ld.skills.integrity_failure'); + expect(record.action).toBe('withheld'); + expect(record.language).toBe('typescript'); + expect(typeof record.reason).toBe('string'); + + // Both keys are named, and `skill_key` keeps the meaning it has on every + // other record — the key the *caller asked for* — so a rule grouping by it + // still works. The key the store actually answered under is the datum that + // makes a broken adapter diagnosable, so it is a parseable field rather + // than prose buried in `reason`. + expect(record.skill_key).toBe('asked-for'); + expect(record.served_key).toBe('served-key'); + + // Verification passed, so there is no hash disagreement to report and the + // two hash fields stay absent rather than being emitted as null. + expect('expected_hash' in record).toBe(false); + expect('observed_hash' in record).toBe(false); + + // The structured attachment is required alongside the text, same as every + // other record. + expect(rest).toHaveLength(1); + expect(rest[0]).toEqual(record); + }); + + it('records the version mismatch on the log surface but not the product signal', async () => { + // The same split the key mismatch gets, made at the same boundary for the + // same population — a broken custom store adapter — so it is asserted in + // both directions here too. + // + // Asserted through `getSkill` specifically, which is the whole reason the + // record exists. `wrong_version` is a public `SkillOutcomeReason`, so a + // `getSkillResult` caller already learns the condition programmatically; but + // `getSkill` is the documented default and collapses it to `null` exactly as + // it collapses `integrity_failure`, so without this record an operator on + // the default accessor has zero visibility into a store answering pins with + // the wrong version. + // + // Neither shipped store can get here — both answer a pin with exactly that + // version or `null`, which is `absent` — so the store is hand-built. + const lying: SkillStore = { + getObject() { + return rawSkill({ key: 'pdf-extraction', version: 9 }); + }, + allObjects() { + return {}; + }, + }; + _setStore(lying); + + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let errorCalls: unknown[][] = []; + let collapsed: Skill | null; + try { + collapsed = await getSkill('pdf-extraction', { version: 2 }); + } finally { + errorCalls = [...spy.mock.calls]; + spy.mockRestore(); + } + + // The skill is withheld, and the default accessor says only `null`. + expect(collapsed).toBeNull(); + // The signal surface saw nothing at all — not merely no integrity signal. + expect(emitter.records).toEqual([]); + + const lines = errorCalls.filter(([first]) => String(first).includes('ld.skills.integrity_failure')); + expect(lines).toHaveLength(1); + const [first, ...rest] = lines[0]; + const line = String(first); + const record = JSON.parse(line.slice(line.indexOf('{'))) as Record; + + // The event identity is reused deliberately: an existing SIEM rule catches + // this case without being rewritten, and the code is what distinguishes it. + expect(record.event).toBe('ld.skills.integrity_failure'); + expect(record.reason_code).toBe('version_mismatch'); + expect(record.action).toBe('withheld'); + expect(record.language).toBe('typescript'); + expect(typeof record.reason).toBe('string'); + + // Both versions are named. `version` is the one requested — the meaning it + // carries on every other record — and `served_version` the one the store + // answered with, parseable rather than buried in prose. + expect(record.skill_key).toBe('pdf-extraction'); + expect(record.version).toBe(2); + expect(record.served_version).toBe(9); + + expect(rest).toHaveLength(1); + expect(rest[0]).toEqual(record); + }); + + it('records the same version mismatch through getSkillResult, once', async () => { + // The reporting accessor is the same code path, so it produces the same + // record — and only one of it. Recording twice would inflate a customer's + // alert count for one failure. + const lying: SkillStore = { + getObject() { + return rawSkill({ key: 'a', version: 9 }); + }, + allObjects() { + return {}; + }, + }; + _setStore(lying); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let errorCalls: unknown[][] = []; + let outcome: Awaited>; + try { + outcome = await getSkillResult('a', { version: 2 }); + } finally { + errorCalls = [...spy.mock.calls]; + spy.mockRestore(); + } + + expect(outcome.reason).toBe('wrong_version'); + expect(outcome.skill).toBeNull(); + + const lines = errorCalls.filter(([first]) => String(first).includes('ld.skills.integrity_failure')); + expect(lines).toHaveLength(1); + const line = String(lines[0][0]); + const record = JSON.parse(line.slice(line.indexOf('{'))) as Record; + expect(record.reason_code).toBe('version_mismatch'); + }); + + it('reports the key mismatch, not the version mismatch, when a store disagrees on both', async () => { + // The two boundary checks can compete, and the order decides both the code + // *and* the caller-visible outcome — so it is fixed rather than left to + // whichever check the implementation happens to reach first. The key check + // runs first: an answer that is not the requested skill at all makes its + // version moot. + const doublyWrong: SkillStore = { + getObject() { + return rawSkill({ key: 'other-key', version: 9 }); + }, + allObjects() { + return {}; + }, + }; + _setStore(doublyWrong); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let errorCalls: unknown[][] = []; + let outcome: Awaited>; + try { + outcome = await getSkillResult('asked-for', { version: 2 }); + } finally { + errorCalls = [...spy.mock.calls]; + spy.mockRestore(); + } + + // `integrity_failure`, not `wrong_version` — the outcome a caller is + // expected to fail closed on wins over the one it is invited to tolerate. + expect(outcome.reason).toBe('integrity_failure'); + + const lines = errorCalls.filter(([first]) => String(first).includes('ld.skills.integrity_failure')); + expect(lines).toHaveLength(1); + const line = String(lines[0][0]); + const record = JSON.parse(line.slice(line.indexOf('{'))) as Record; + expect(record.reason_code).toBe('key_mismatch'); + expect(record.served_key).toBe('other-key'); + // One record, not two: the first check to fire returns. + expect('served_version' in record).toBe(false); + }); +}); + +// ─── getSkills ───────────────────────────────────────────────────────── + +describe('getSkills', () => { + let store: InMemorySkillStore; + + beforeEach(() => { + store = new InMemorySkillStore(); + _setStore(store); + }); + + it('accepts a mixed sequence of references and bare strings', async () => { + store.put(rawSkill({ key: 'a', version: 1 })); + store.put(rawSkill({ key: 'b', version: 5 })); + const found = await getSkills(['a', { key: 'b', version: 5 }]); + expect(found.map((s) => s.key)).toEqual(['a', 'b']); + }); + + it('preserves input order for the skills found', async () => { + for (const key of ['a', 'b', 'c']) store.put(rawSkill({ key })); + const found = await getSkills(['c', 'a', 'b']); + expect(found.map((s) => s.key)).toEqual(['c', 'a', 'b']); + }); + + it('omits missing entries rather than returning placeholders', async () => { + store.put(rawSkill({ key: 'a' })); + const found = await getSkills(['a', 'missing']); + expect(found).toHaveLength(1); + expect(found[0].key).toBe('a'); + }); + + it('omits a wrong-version entry', async () => { + store.put(rawSkill({ key: 'a', version: 2 })); + expect(await getSkills([{ key: 'a', version: 1 }])).toEqual([]); + }); + + it('omits integrity failures and records the signal once', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + store.put(rawSkill({ key: 'good-one' })); + store.put(rawSkill({ key: 'tampered', contentHash: '0'.repeat(64) })); + store.put(rawSkill({ key: 'good-two' })); + + const found = await getSkills(['good-one', 'tampered', 'good-two']); + + expect(found.map((s) => s.key)).toEqual(['good-one', 'good-two']); + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('returns an empty list for an empty input', async () => { + expect(await getSkills([])).toEqual([]); + }); +}); + +// ─── Withholding summary ─────────────────────────────────────────────── + +describe('withholding summary', () => { + let store: InMemorySkillStore; + let warnSpy: ReturnType; + const warnings = (): string[] => warnSpy.mock.calls.map((call) => String(call[0])); + const tampered = (key: string): RawSkillObject => rawSkill({ key, contentHash: '0'.repeat(64) }); + + beforeEach(() => { + store = new InMemorySkillStore(); + _setStore(store); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('warns once, naming the content hash, when allSkills withholds everything', async () => { + store.put(tampered('a')); + expect(await allSkills()).toEqual([]); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toMatch(/contentHash/); + }); + + it('warns with the counts when allSkills withholds some', async () => { + store.put(rawSkill({ key: 'good' })); + store.put(tampered('bad')); + expect((await allSkills()).map((s) => s.key)).toEqual(['good']); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toMatch(/1 of 2/); + }); + + it('warns once per getSkills batch, not once per skill', async () => { + store.put(tampered('a')); + store.put(tampered('b')); + expect(await getSkills(['a', 'b'])).toEqual([]); + expect(warnings()).toHaveLength(1); + }); + + it('does not warn when getSkills asks for keys the store does not hold', async () => { + // An empty store at boot is not an integrity problem. + expect(await getSkills(['a', 'b'])).toEqual([]); + expect(warnings()).toEqual([]); + }); + + it('does not warn when a getSkills pin misses', async () => { + store.put(rawSkill({ key: 'a', version: 2 })); + expect(await getSkills([{ key: 'a', version: 1 }])).toEqual([]); + expect(warnings()).toEqual([]); + }); + + it('does not warn when the store throws during getSkills', async () => { + const throwing: SkillStore = { + getObject: () => { + throw new Error('down'); + }, + allObjects: () => ({}), + }; + _setStore(throwing); + expect(await getSkills(['a'])).toEqual([]); + expect(warnings()).toEqual([]); + }); + + it('counts only what the store served when getSkills mixes misses and withholding', async () => { + store.put(rawSkill({ key: 'good' })); + store.put(tampered('bad')); + expect((await getSkills(['good', 'bad', 'missing'])).map((s) => s.key)).toEqual(['good']); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toMatch(/1 of 2/); + }); + + it('does not warn when nothing was withheld', async () => { + store.put(rawSkill({ key: 'a' })); + expect(await getSkills(['a'])).toHaveLength(1); + expect(await allSkills()).toHaveLength(1); + expect(warnings()).toEqual([]); + }); + + it('does not count a key as withheld when another version of it resolved', async () => { + // A malformed object beside a well-formed version of the same key. + store.put(rawSkill({ key: 'a', version: 1 })); + store.put(rawSkill({ key: 'a', version: 'not-a-version' as unknown as number })); + expect((await allSkills()).map((s) => s.key)).toEqual(['a']); + expect(warnings()).toEqual([]); + }); +}); + +// ─── allSkills ───────────────────────────────────────────────────────── + +describe('allSkills', () => { + it('returns every verified skill the store holds', async () => { + const store = new InMemorySkillStore(); + for (const key of ['a', 'b', 'c']) store.put(rawSkill({ key })); + _setStore(store); + expect((await allSkills()).map((s) => s.key).sort()).toEqual(['a', 'b', 'c']); + }); + + it('omits skills that fail verification', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'good' })); + store.put(rawSkill({ key: 'bad', contentHash: '0'.repeat(64) })); + _setStore(store); + expect((await allSkills()).map((s) => s.key)).toEqual(['good']); + }); + + it('returns the newest version per key', async () => { + // A list carrying two versions of one key is not a set of skills, and + // downstream `//SKILL.md` is a single path. The store holds both, + // so the collapse has to happen here. Newer seeded first, so insertion order + // cannot pass for ordering. + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 5 })); + store.put(rawSkill({ key: 'a', version: 2 })); + store.put(rawSkill({ key: 'b', version: 1 })); + _setStore(store); + + const found = await allSkills(); + + expect(found.filter((s) => s.key === 'a')).toHaveLength(1); + expect(found.find((s) => s.key === 'a')?.version).toBe(5); + expect(found.map((s) => s.key).sort()).toEqual(['a', 'b']); + }); + + it('files a listed object under its own key, with no key_mismatch for a disagreeing map key', async () => { + // The counterpart to the pinned path's `key_mismatch`, and the asymmetry is + // deliberate rather than a gap. A listing carries no requested key, so there + // is nothing for the object's key to disagree *with*: identity comes off the + // object, and the store's map key is used for one thing only — attributing a + // failure when the object's own key is unusable. + // + // The seam never promised a map key spells a skill key, either: a store + // holding several versions of one key has reason to spell it `key:version`, + // which is exactly what FDv2SkillStore does. Pinned because the asymmetry + // with the pinned path is surprising enough to invite a "fix" that would + // break every multi-version store. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + _setStore(new DictStore({ 'filed-under-this': rawSkill({ key: 'its-own-key' }) })); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let errorCalls: unknown[][] = []; + let found: Skill[]; + try { + found = await allSkills(); + } finally { + errorCalls = [...spy.mock.calls]; + spy.mockRestore(); + } + + expect(found.map((s) => s.key)).toEqual(['its-own-key']); + // Neither surface fires: nothing failed. + expect(emitter.records).toEqual([]); + expect(errorCalls.filter(([first]) => String(first).includes('ld.skills.integrity_failure'))).toEqual([]); + }); + + it('files a listed object under its own version, with no version_mismatch for a disagreeing map key', async () => { + // The counterpart to the pinned path's `version_mismatch`, and the same + // asymmetry as the key-mismatch guard above. A listing carries no requested + // version, so there is nothing for the object's version to disagree *with*: + // `resolveFromStore`'s equality check is only reached with a pin. + // + // The map key is the thing that invites a "fix" here, because + // `FDv2SkillStore` spells it `key:version` — so a store whose map key says + // version 3 while the object says 5 looks like a disagreement and is not + // one. Pinned because a check added on the listing path would report a + // mismatch for every store that spells its map keys that way. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + _setStore(new DictStore({ 'a:3': rawSkill({ key: 'a', version: 5 }) })); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let errorCalls: unknown[][] = []; + let found: Skill[]; + try { + found = await allSkills(); + } finally { + errorCalls = [...spy.mock.calls]; + spy.mockRestore(); + } + + expect(found.map((sk) => [sk.key, sk.version])).toEqual([['a', 5]]); + // Neither surface fires: nothing failed. + expect(emitter.records).toEqual([]); + expect(errorCalls.filter(([first]) => String(first).includes('ld.skills.integrity_failure'))).toEqual([]); + }); + + it('keeps an unusable object in the set so verification withholds it with a signal', async () => { + // Filtering it here instead would silently shrink the resolved set, which on + // the materialization path is indistinguishable from a revocation and would + // let prune delete a live skill's last good copy. The recorded signal is + // what proves the object reached verification rather than being dropped. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'good', version: 1 })); + store.put(rawSkill({ key: 'unusable', version: 'nope' as unknown as number })); + _setStore(store); + + const found = await allSkills(); + + expect(found.map((s) => s.key)).toEqual(['good']); + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('records no signal for a key an unusable sibling did not stop resolving', async () => { + // The other side of the rule above. A store may hold a malformed object + // beside a well-formed version of the same key; the well-formed one + // resolves, so the key was never withheld and must not be reported as + // though it were. Keeping the sibling could only add an integrity failure + // for a skill whose integrity is not in question. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 1 })); + store.put(rawSkill({ key: 'a', version: 'nope' as unknown as number })); + _setStore(store); + + const found = await allSkills(); + + expect(found.map((s) => s.key)).toEqual(['a']); + expect(emitter.signals(INTEGRITY_SIGNAL)).toEqual([]); + }); + + it('returns an empty list for an empty store', async () => { + _setStore(new InMemorySkillStore()); + expect(await allSkills()).toEqual([]); + }); + + it('reports a non-object listing as a broken store, not an empty one', async () => { + // `allSkills` has no way to report the difference, so it returns an empty + // list either way — but the reason has to reach the caller that does act on + // it. Collapsing the answer to "no skills" reads downstream as "every skill + // was revoked". + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const brokenListing: SkillStore = { + getObject() { + return null; + }, + allObjects() { + return null as unknown as Record; + }, + }; + _setStore(brokenListing); + + expect(await allSkills()).toEqual([]); + + const { objects, error } = allRawObjects(requireStore()); + expect(objects).toEqual({}); + expect(error).toBe('the skill store listed skills as null rather than an object'); + expect(spy.mock.calls.map(([line]) => String(line))).toEqual([ + '[LaunchDarkly] Skill store listed skills as null rather than an object', + '[LaunchDarkly] Skill store listed skills as null rather than an object', + ]); + } finally { + spy.mockRestore(); + } + }); + + it('names the type a broken listing came back as', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const listingAs = (value: unknown): SkillStore => ({ + getObject: () => null, + allObjects: () => value as Record, + }); + expect(allRawObjects(listingAs([])).error).toBe('the skill store listed skills as array rather than an object'); + expect(allRawObjects(listingAs('x')).error).toBe('the skill store listed skills as string rather than an object'); + expect(allRawObjects(listingAs(undefined)).error).toBe( + 'the skill store listed skills as undefined rather than an object', + ); + expect(allRawObjects(new InMemorySkillStore())).toEqual({ objects: {}, error: null }); + } finally { + spy.mockRestore(); + } + }); +}); + +// ─── Integrity verification ──────────────────────────────────────────── + +describe('integrity verification', () => { + it('withholds a skill whose hash does not match', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', contentHash: 'a'.repeat(64) })); + _setStore(store); + + expect(await getSkill('a')).toBeNull(); + const [props] = emitter.signals(INTEGRITY_SIGNAL); + expect(props.expected_hash).toBe('a'.repeat(64)); + expect(props.observed_hash).toBe(hash(SKILL_BODY)); + }); + + it('withholds content tampered by a single byte', async () => { + const raw = rawSkill({ key: 'a' }); + raw.content = `${raw.content as string}x`; + _setStore(new DictStore({ a: raw })); + expect(await getSkill('a')).toBeNull(); + }); + + it('rejects content one byte over the size cap even when its hash matches', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', content: OVERSIZE })); + _setStore(store); + + expect(await getSkill('a')).toBeNull(); + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('reports over_size_cap, not not_utf8, for over-cap content that also carries a lone surrogate (§3.21)', async () => { + // The checks run in a fixed order — shape, size, encoding, hash — and size + // precedes encoding deliberately: running an encoding pass over a 10 MiB + // body before rejecting it for being 10 MiB is a DoS foothold. This is the + // one boundary where the order is observable. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + errorSpy.mockClear(); + const content = `\ud800${OVERSIZE}`; + _setStore(new DictStore({ a: rawSkill({ key: 'a', content, contentHash: hash(Buffer.from(content, 'utf-8')) }) })); + + expect(await getSkill('a')).toBeNull(); + + const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('"reason_code":"over_size_cap"'); + expect(logged).not.toContain('not_utf8'); + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('accepts content at exactly the size cap', async () => { + const atCap = 'x'.repeat(MAX_SKILL_CONTENT_BYTES); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', content: atCap })); + _setStore(store); + expect((await getSkill('a'))?.content).toHaveLength(MAX_SKILL_CONTENT_BYTES); + }); + + it('accepts a key at the 256-character bound from the store', async () => { + // The accepting side of the <= 256 bound. writeSkills cannot reach + // it (a key is one directory name and NAME_MAX is 255), so config + // validation and this accessor-side revalidation are the only two layers + // where 256 is observable at all. + const key = 'a'.repeat(256); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key })); + _setStore(store); + expect((await getSkill(key))?.key).toBe(key); + }); + + it.each([ + ['uppercase', 'Evil'], + ['leading dash', '-leading-dash'], + ['leading dot', '.hidden'], + ['embedded space', 'has space'], + ['path separator', 'a/b'], + ['traversal', '../escape'], + ['empty', ''], + ['overlong', 'x'.repeat(257)], + ['trailing newline', 'trailing\n'], + ])('rejects an invalid key served by the store: %s', async (_label, badKey) => { + // A hostile store may serve any key — the accessor revalidates. + const raw = rawSkill({ key: 'placeholder' }); + raw.key = badKey; + _setStore(new DictStore({ [badKey]: raw })); + expect(await getSkill(badKey)).toBeNull(); + }); + + it.each([ + ['zero', 0], + ['negative', -1], + ['non-integer', 2.5], + ['string', '2'], + ['null', null], + ['boolean', true], + ['undefined', undefined], + ['NaN', Number.NaN], + ])('rejects an invalid version served by the store: %s', async (_label, badVersion) => { + const raw = rawSkill({ key: 'a' }); + raw.version = badVersion as number; + _setStore(new DictStore({ a: raw })); + expect(await getSkill('a')).toBeNull(); + }); + + it('rejects a raw object with no content', async () => { + const raw = rawSkill({ key: 'a' }); + delete raw.content; + _setStore(new DictStore({ a: raw })); + expect(await getSkill('a')).toBeNull(); + }); + + it('rejects a raw object with no contentHash', async () => { + const raw = rawSkill({ key: 'a' }); + delete raw.contentHash; + _setStore(new DictStore({ a: raw })); + expect(await getSkill('a')).toBeNull(); + }); + + it('rejects a non-object raw entry — absent when pinned, not_an_object when listed (§3.21)', async () => { + // The asymmetry is the contract. A pinned lookup that comes back as a + // non-object is a broken store adapter answering "nothing", reported as + // `absent` with no signal and no log record — firing a tampering signal on + // every broken adapter would drown the real ones. The listing path hands + // every raw value straight to verification, which is where `not_an_object` + // fires. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + // `spyOn` on an already-spied method hands back the existing spy with its + // history, so clear it: only what *this* test logs may count. + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + errorSpy.mockClear(); + _setStore(new DictStore({ a: 'not an object' as unknown as RawSkillObject })); + + expect(await getSkill('a')).toBeNull(); + const outcome = await getSkillResult('a'); + expect(outcome.reason).toBe('absent'); + expect(emitter.records).toEqual([]); + expect(errorSpy).not.toHaveBeenCalled(); + + expect(await allSkills()).toEqual([]); + const signals = emitter.signals(INTEGRITY_SIGNAL); + expect(signals).toHaveLength(1); + expect(signals[0].skill_key).toBe(''); + const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(logged).toContain('"reason_code":"not_an_object"'); + }); + + it('rejects an uppercase hash — hashes are lowercase hex', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', contentHash: hash(SKILL_BODY).toUpperCase() })); + _setStore(store); + expect(await getSkill('a')).toBeNull(); + }); + + it('verifies multi-byte UTF-8 content byte-exactly', async () => { + const emoji = '---\nname: 🎉\n---\nUnicode ✨ body\n'; + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', content: emoji })); + _setStore(store); + expect((await getSkill('a'))?.content).toEqual(new TextEncoder().encode(emoji)); + }); + + it('withholds content that has no UTF-8 encoding, even when its hash matches', async () => { + // An unpaired surrogate has no UTF-8 encoding, and Node's Buffer.from + // *silently substitutes* U+FFFD rather than raising, so only an explicit + // round-trip check catches it — without that check a store can supply the + // hash of the substituted bytes and have fabricated content pass + // verification. + // + // contentHash is deliberately the hash of the lossy encoding, so the hash + // comparison is NOT what rejects this. If the round-trip guard is removed, + // this skill verifies and getSkill returns content LaunchDarkly never sent. + const content = 'hi \ud800 there'; + const substituted = Buffer.from(content, 'utf-8'); + expect(substituted.toString('utf-8')).not.toBe(content); // the substitution really happens + + const store = new DictStore({ + a: { key: 'a', version: 1, content, contentHash: createHash('sha256').update(substituted).digest('hex') }, + }); + _setStore(store); + + expect(await getSkill('a')).toBeNull(); + }); + + it('verifies content that begins with a byte-order mark', async () => { + // The counterpart to the surrogate case above, and the reason the round-trip + // guard decodes with `ignoreBOM: true`. A default TextDecoder consumes a + // leading U+FEFF, which would make authentic BOM-prefixed content round-trip + // to a shorter string and be withheld as not_utf8 despite hashing correctly. + const content = '\ufeff---\nname: bom\n---\nbody\n'; + const encoded = new TextEncoder().encode(content); + const store = new DictStore({ + a: { key: 'a', version: 1, content, contentHash: createHash('sha256').update(encoded).digest('hex') }, + }); + _setStore(store); + + expect((await getSkill('a'))?.content).toEqual(encoded); + }); + + it('records the integrity signal for unencodable content', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const content = 'hi \ud800 there'; + _setStore( + new DictStore({ + a: { + key: 'a', + version: 1, + content, + contentHash: createHash('sha256').update(Buffer.from(content, 'utf-8')).digest('hex'), + }, + }), + ); + + await getSkill('a'); + + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('reports a throwing store as no result rather than propagating', async () => { + _setStore({ + getObject() { + throw new Error('transport failure'); + }, + allObjects() { + throw new Error('transport failure'); + }, + }); + expect(await getSkill('a')).toBeNull(); + expect(await getSkills(['a'])).toEqual([]); + expect(await allSkills()).toEqual([]); + }); +}); + +// ─── Telemetry seam (accessor half) ──────────────────────────────────── + +describe('telemetry seam, accessor half', () => { + it('the default emitter is a no-op and never raises', async () => { + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', contentHash: '0'.repeat(64) })); + _setStore(store); + // No emitter injected. + expect(await getSkill('a')).toBeNull(); + }); + + it('records the integrity failure with the exact property keys', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', version: 4, contentHash: 'b'.repeat(64) })); + _setStore(store); + + await getSkill('a'); + + const [props] = emitter.signals(INTEGRITY_SIGNAL); + expect(props.skill_key).toBe('a'); + expect(props.version).toBe(4); + expect(props.expected_hash).toBe('b'.repeat(64)); + expect(props.observed_hash).toBe(hash(SKILL_BODY)); + expect(props.language).toBe('typescript'); + // Exactly those five and no more: the signal's property set is a documented + // allowlist, so the assertion is on the whole set rather than on each + // member. The five present is half a test. + expect(Object.keys(props).sort()).toEqual(['expected_hash', 'language', 'observed_hash', 'skill_key', 'version']); + }); + + it('keeps the four record-only fields out of the signal', async () => { + // The log record is the larger of the two surfaces and carries four fields + // the signal must not: a stable `event` identity, the `action` taken, the + // human-readable `reason`, and the machine-parseable `reason_code`. Letting + // them leak into the signal is the regression this guards — the signal's + // property set is an allowlist that does not grow, and the two surfaces are + // deliberately different sizes. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + _setStore(new DictStore({ a: rawSkill({ key: 'a', contentHash: 'b'.repeat(64) }) })); + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + await getSkill('a'); + } finally { + spy.mockRestore(); + } + + const [props] = emitter.signals(INTEGRITY_SIGNAL); + for (const field of ['event', 'action', 'reason', 'reason_code']) { + expect(field in props, field).toBe(false); + } + }); + + it('never puts the skill body in a signal', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a', contentHash: 'c'.repeat(64) })); + _setStore(store); + + await getSkill('a'); + + for (const [, props] of emitter.records) { + for (const value of Object.values(props)) { + expect(String(value)).not.toContain('Do the thing.'); + } + } + }); + + // `skill_key` and `expected_hash` are copied off the wire, + // so a hostile store can smuggle the body through either one. The sweep above + // cannot detect that: it passes a well-formed 64-char digest, so neither + // shape-check branch ever runs. These two cases are what make the rule + // observable. Assert the body's absence, not the placeholder's spelling. + + it('redacts a skill body smuggled through contentHash', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const body = 'UNIQUE-SECRET-BODY-VIA-HASH'; + _setStore(new DictStore({ a: { key: 'a', version: 1, content: body, contentHash: body } })); + + expect(await getSkill('a')).toBeNull(); + + const signals = emitter.signals(INTEGRITY_SIGNAL); + expect(signals).toHaveLength(1); + for (const value of Object.values(signals[0])) { + expect(String(value)).not.toContain(body); + } + }); + + it('redacts a skill body smuggled through the key', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + // Not a valid skill key, so it is the invalid-key branch that must redact it. + const body = 'UNIQUE-SECRET-BODY-VIA-KEY/../x'; + _setStore(new DictStore({ [body]: { key: body, version: 1, content: 'x', contentHash: 'y' } })); + + expect(await getSkill(body)).toBeNull(); + + const signals = emitter.signals(INTEGRITY_SIGNAL); + expect(signals).toHaveLength(1); + for (const value of Object.values(signals[0])) { + expect(String(value)).not.toContain(body); + } + }); + + it('makes no client.track call from any accessor', async () => { + const mockClient = makeMockLdClient(); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'a' })); + store.put(rawSkill({ key: 'bad', contentHash: '0'.repeat(64) })); + await initClient(mockClient); + setSkillStore(store); + // initClient itself may report `$ld:ai:sdk:info`; only the accessors are under test. + mockClient.track.mockClear(); + + await getSkill('a'); + await getSkill('bad'); + await getSkills(['a']); + await allSkills(); + skillRefs({ model: { name: 'm' }, provider: { name: 'p' }, instructions: 'i', skills: [{ key: 'a', version: 1 }] }); + + expect(mockClient.track).not.toHaveBeenCalled(); + }); + + it('a throwing emitter never breaks the operation', async () => { + _setEmitterForTesting(new ThrowingEmitter()); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'bad', contentHash: '0'.repeat(64) })); + store.put(rawSkill({ key: 'good' })); + _setStore(store); + + expect(await getSkill('bad')).toBeNull(); + expect((await getSkill('good'))?.key).toBe('good'); + }); + + it('records no signal outside the approved set', async () => { + // The three names are an allowlist, not a floor. Asserted over the + // recorded strings, so nothing here mandates a module-level constant. + // + // Guards the most likely regression: an implementation that also emits + // `AgentControl Skill Content Retrieved` from getSkill, or + // `AgentControl Skill SDK Reference Returned` from skillRefs, passes every + // other test in this block. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const store = new InMemorySkillStore(); + store.put(rawSkill({ key: 'good' })); + store.put(rawSkill({ key: 'tampered', contentHash: '0'.repeat(64) })); + _setStore(store); + + expect(await getSkill('good')).not.toBeNull(); + expect(await getSkill('tampered')).toBeNull(); + await getSkills(['good', 'tampered']); + await allSkills(); + skillRefs({ + model: { name: 'm' }, + provider: { name: 'p' }, + instructions: 'i', + skills: [{ key: 'good', version: 1 }], + }); + + const recorded = emitter.names(); + const unapproved = [...recorded].filter((name) => !APPROVED_SIGNALS.has(name)); + expect(unapproved).toEqual([]); + for (const removed of REMOVED_SIGNALS) expect(recorded.has(removed)).toBe(false); + // Positive control: a subset assertion is satisfied vacuously by an + // implementation that records nothing at all. + expect(recorded.has(INTEGRITY_SIGNAL)).toBe(true); + }); +}); + +// ─── The local integrity-failure log record ──────────────────────────── + +describe('integrity-failure log record', () => { + // A documented customer-facing contract: operators point a SIEM at this line + // and alert on it, and it is the *only* detection surface when telemetry is + // off. So these assertions parse the JSON back rather than matching message + // text, and they cover the field set, not just the fact that something logged. + const EVENT = 'ld.skills.integrity_failure'; + + type LoggedRecord = { + /** The message text — the first argument. */ + line: string; + /** The mapping parsed back out of the message text. */ + record: Record; + /** The structured attachment — every argument after the first. */ + rest: unknown[]; + }; + + async function logged(run: () => Promise): Promise { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let calls: unknown[][] = []; + try { + await run(); + } finally { + // Read the calls out before restoring: `mockRestore` also resets the + // recorded history, so a read afterwards sees nothing. + calls = [...spy.mock.calls]; + spy.mockRestore(); + } + return calls + .filter(([first]) => String(first).includes(EVENT)) + .map(([first, ...rest]) => { + const line = String(first); + return { line, record: JSON.parse(line.slice(line.indexOf('{'))) as Record, rest }; + }); + } + + const surrogate = 'hi \ud800 there'; + + // `getSkill` reaches seven of the eight call sites. The eighth is only + // reachable through the listing path: `resolveFromStore` treats a non-object + // store entry as absence before `verifyRawSkill` ever sees it, while + // `allSkills` hands every raw value straight in. + const GET = () => getSkill('a'); + const ALL = () => allSkills(); + // The tenth code needs a *pinned* retrieval: without a wanted version there is + // nothing for the store's answer to disagree with. + const PINNED = () => getSkill('a', { version: 2 }); + + /** A one-key store serving exactly what a hostile store might serve. */ + const serving = (raw: unknown) => () => new DictStore({ a: raw as RawSkillObject }); + + /** + * A store that answers a pin with a well-formed object at another version. + * + * Hand-built on purpose: neither shipped store can produce this outcome. + * `FDv2SkillStore` and `InMemorySkillStore` both answer a pin with exactly + * that version or with `null`, so a pin that misses reads as `absent` — a + * test that tried to provoke this from a shipped store would get `absent` + * instead and silently stop covering the case. Only `getObject` is + * implemented, since the rest of the seam is optional. + */ + const versionLying = (served: number) => (): SkillStore => ({ + getObject(_kind: string, key: string) { + return rawSkill({ key, version: served }); + }, + allObjects() { + return {}; + }, + }); + + /** A store serving a wire-shaped object with fields spoiled or dropped. */ + const spoiled = + (overrides: Partial & { key?: unknown }, drop: Array<'content' | 'contentHash'> = []) => + () => { + const raw = rawSkill({ key: 'a', ...overrides }); + for (const field of drop) delete raw[field]; + return new DictStore({ a: raw }); + }; + + /** One store per `reason_code`, each reaching a different call site. */ + const cases: Array<[string, () => SkillStore, () => Promise]> = [ + ['not_an_object', serving('not an object'), ALL], + ['invalid_key', spoiled({ key: 'Not/A/Key' }), GET], + ['invalid_version', spoiled({ version: 0 }), GET], + ['missing_content', spoiled({}, ['content']), GET], + ['missing_content_hash', spoiled({}, ['contentHash']), GET], + ['not_utf8', spoiled({ content: surrogate, contentHash: hash(Buffer.from(surrogate, 'utf-8')) }), GET], + ['over_size_cap', spoiled({ content: OVERSIZE }), GET], + ['hash_mismatch', spoiled({ contentHash: 'd'.repeat(64) }), GET], + // The ninth is the odd one out: it is not a verification failure, so it + // comes from `recordKeyMismatch` rather than from a call site inside + // `verifyRawSkill`. It is in the same vocabulary anyway, because a + // customer's detection rule cares that integrity failed and not about which + // layer noticed. A store that serves a perfectly valid object under the + // wrong key reaches it: everything `verifyRawSkill` checks passes. + ['key_mismatch', spoiled({ key: 'served-under-this' }), GET], + // The tenth is the other boundary code, and the same kind of odd one out: + // everything `verifyRawSkill` checks passes, and the disagreement is between + // the object and the *request* rather than inside the object. Reachable only + // with a pin, and only through a store that lies about which version it + // served. + ['version_mismatch', versionLying(9), PINNED], + ]; + + it.each(cases)('logs one record carrying reason_code %s', async (code, makeStore, run) => { + _setStore(makeStore()); + + const records = await logged(run); + + expect(records).toHaveLength(1); + const { record } = records[0]; + expect(record.reason_code).toBe(code); + expect(record.event).toBe(EVENT); + expect(record.action).toBe('withheld'); + expect(record.language).toBe('typescript'); + expect(typeof record.reason).toBe('string'); + }); + + it('covers the whole reason_code vocabulary and nothing else', () => { + // Eight of the ten are one per call site of `recordIntegrityFailure`; the + // other two, `key_mismatch` and `version_mismatch`, come from the two + // boundary recorders. Every language implementation emits the same ten, so + // an eleventh in one SDK only is the regression this test exists to catch. + expect(cases.map(([code]) => code).sort()).toEqual([ + 'hash_mismatch', + 'invalid_key', + 'invalid_version', + 'key_mismatch', + 'missing_content', + 'missing_content_hash', + 'not_an_object', + 'not_utf8', + 'over_size_cap', + 'version_mismatch', + ]); + }); + + it('keeps the reason_code and outcome vocabularies apart, spellings included', () => { + // One condition now has a token in each vocabulary, with *different* + // spellings: the code is `version_mismatch`, the outcome `wrong_version`. + // The split is deliberate — the code names which check failed, the outcome + // what the caller got — so a detection rule matching one string is never + // ambiguous about which surface it was written against. Asserting both + // closed sets in one place is what keeps a well-meaning "consistency" fix + // from collapsing them into one spelling. + const codes = cases.map(([code]) => code); + const outcomes: experimental.SkillOutcomeReason[] = [ + 'absent', + 'integrity_failure', + 'ok', + 'store_unavailable', + 'wrong_version', + ]; + + expect(codes).toContain('version_mismatch'); + expect(codes).not.toContain('wrong_version'); + expect(outcomes).toContain('wrong_version'); + expect(outcomes).not.toContain('version_mismatch' as experimental.SkillOutcomeReason); + // Ten codes, five outcomes. The finer vocabulary grew; the public one did not. + expect(codes).toHaveLength(10); + expect(outcomes).toHaveLength(5); + }); + + it('fires the record without the signal for the two boundary codes, and never the reverse', async () => { + // The one-directional exception to "the two surfaces agree", and there are + // exactly two cases. Swept across the whole vocabulary rather than asserted + // on those two, because what makes it safe is that *only* they are + // signal-free: an implementation that dropped the signal for some other code + // would satisfy a two-case assertion. + // + // Each is asserted separately rather than as a group — an implementation + // that emitted the signal for either one would satisfy every other + // assertion in this block. + const recordOnly = new Set(['key_mismatch', 'version_mismatch']); + + for (const [code, makeStore, run] of cases) { + _clearState(); + _setStore(makeStore()); + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + + const records = await logged(run); + expect(records, code).toHaveLength(1); + + const signals = emitter.signals(INTEGRITY_SIGNAL); + if (recordOnly.has(code)) { + // Not merely no integrity signal: the emitter saw nothing at all. + expect(emitter.records, code).toEqual([]); + expect(signals, code).toHaveLength(0); + } else { + expect(signals, code).toHaveLength(1); + } + } + }); + + it('logs the event name and nothing but the record, so a grep finds it', async () => { + _setStore(new DictStore({ a: rawSkill({ key: 'a', contentHash: 'd'.repeat(64) }) })); + + const [{ line, record }] = await logged(() => getSkill('a')); + + expect(line).toBe(`[LaunchDarkly] ${EVENT} ${JSON.stringify(record)}`); + }); + + it('emits the record twice over: in the message text and as structured data', async () => { + // Both forms are required, and neither is sufficient alone. A default + // `console.error` transport shows only the message text, so a + // structured-only record is invisible under the setup most customers have — + // and severity cannot stand in for it, because other store-failure paths in + // this module also log at error level. A structured pipeline, conversely, + // wants the mapping as *data* rather than as text it has to re-parse out of + // a prefixed line. Python's half of this is `extra={"ld_skills": record}`. + _setStore(new DictStore({ a: rawSkill({ key: 'a', version: 7, contentHash: 'd'.repeat(64) }) })); + + const [{ line, record, rest }] = await logged(() => getSkill('a')); + + // Form one: the event identity verbatim in the text, followed by the JSON. + expect(line.startsWith(`[LaunchDarkly] ${EVENT} `)).toBe(true); + // Form two: the same mapping, attached rather than serialized. + expect(rest).toHaveLength(1); + const attached = rest[0] as Record; + expect(typeof attached).toBe('object'); + expect(attached).not.toBeNull(); + // The *same* mapping — asserted field-for-field, because an attachment that + // had drifted from the text would defeat the point of having both. + expect(attached).toEqual(record); + // And key order agrees too, which is what keeps the two halves from + // diverging the day someone reorders one of them. + expect(Object.keys(attached)).toEqual(Object.keys(record)); + }); + + it('attaches the record on every reason_code, not just one path', async () => { + // The attachment is added at the single `console.error` call site, so one + // case would nearly prove it — but "nearly" is how a second call site gets + // added later without one. Sweep the vocabulary. + for (const [code, makeStore, run] of cases) { + _clearState(); + _setStore(makeStore()); + + const [{ record, rest }] = await logged(run); + + expect(rest, code).toHaveLength(1); + expect(rest[0], code).toEqual(record); + } + }); + + it('orders keys alphabetically, so the JSON is byte-identical across SDKs', async () => { + // Every SDK emits these keys sorted. Insertion order here is what makes the + // outputs comparable with one parser and one alert rule. + _setStore(new DictStore({ a: rawSkill({ key: 'a', version: 7, contentHash: 'd'.repeat(64) }) })); + const [{ record }] = await logged(() => getSkill('a')); + const keys = Object.keys(record); + expect(keys).toEqual([...keys].sort()); + expect(keys).toEqual([ + 'action', + 'event', + 'expected_hash', + 'language', + 'observed_hash', + 'reason', + 'reason_code', + 'skill_key', + 'version', + ]); + }); + + it('sorts the key_mismatch record too, with served_key in its alphabetical place', async () => { + // `served_key` is the one record-only field beyond the four, and it is built + // on a separate path — so the sort that makes every other record + // byte-comparable across SDKs has to be asserted here independently rather + // than assumed from the case above. + _setStore(new DictStore({ a: rawSkill({ key: 'served-under-this' }) })); + + const [{ record }] = await logged(() => getSkill('a')); + + const keys = Object.keys(record); + expect(keys).toEqual([...keys].sort()); + // No `version`, and that is deliberate rather than an omission: the served + // object's version verified fine and is not what disqualified the answer. + // Reporting it beside a `skill_key` that means the *requested* key would + // also mix the two frames in one record, and `served_key` already says what + // came back. + expect(keys).toEqual(['action', 'event', 'language', 'reason', 'reason_code', 'served_key', 'skill_key']); + }); + + it('sorts the version_mismatch record too, with served_version in its alphabetical place', async () => { + // The second record-only field, on a third construction path, so the sort + // that makes every other record byte-comparable across SDKs is asserted here + // independently as well. `served_key` and `served_version` sort adjacently, + // so a reader who knows one record's shape can predict the other's — even + // though the two fields never appear on the same record. + _setStore(versionLying(9)()); + + const [{ record }] = await logged(() => getSkill('a', { version: 2 })); + + const keys = Object.keys(record); + expect(keys).toEqual([...keys].sort()); + expect(keys).toEqual([ + 'action', + 'event', + 'language', + 'reason', + 'reason_code', + 'served_version', + 'skill_key', + 'version', + ]); + // No `served_key` here, and no hash fields: verification passed, so the + // hashes are not what disqualified the answer, and the key agreed. + expect('served_key' in record).toBe(false); + expect('expected_hash' in record).toBe(false); + expect('observed_hash' in record).toBe(false); + }); + + it('records both versions as integers, not as strings', async () => { + // The type, not only the value. The sorted-key JSON is compared byte for + // byte across SDKs, and `"served_version":9` and `"served_version":"9"` are + // not the same line — so a language that stringified either number would + // pass a value assertion and still break a cross-language parser. + _setStore(versionLying(9)()); + + const [{ line, record }] = await logged(() => getSkill('a', { version: 2 })); + + for (const field of ['version', 'served_version']) { + expect(typeof record[field], field).toBe('number'); + expect(Number.isInteger(record[field]), field).toBe(true); + } + // `version` is the version **requested**, the meaning it carries on every + // other record; `served_version` is the one the store answered with. + expect(record.version).toBe(2); + expect(record.served_version).toBe(9); + expect(line).toContain('"served_version":9'); + expect(line).toContain('"version":2'); + }); + + it('replaces a served_version that is not a valid version', async () => { + // Unreachable today — `verifyRawSkill`'s `invalid_version` check accepts the + // served version before this path runs, so it is an integer by + // construction. Asserted anyway, for the two reasons `served_key`'s guard + // is: that is a property of the current call order rather than of the + // recorder, and the check is what keeps the field an integer the + // cross-language byte comparison can rely on. The Python SDK emits the same + // placeholder for the same input. + const body = 'UNIQUE-SERVED-VERSION-BODY'; + const { recordVersionMismatch } = await import('../skills-core.js'); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let calls: unknown[][] = []; + try { + recordVersionMismatch('asked-for', 2, body as unknown as number); + } finally { + calls = [...spy.mock.calls]; + spy.mockRestore(); + } + + const line = String(calls[0][0]); + const record = JSON.parse(line.slice(line.indexOf('{'))) as Record; + expect(record.served_version).toBe(''); + expect(line).not.toContain(body); + // The requested version is the caller's own pin, not a store-controlled + // value, so it is reported as given rather than coerced — hiding a caller's + // mistyped pin from their own log would be the worse failure. + expect(record.version).toBe(2); + }); + + it('redacts a hostile served_key', async () => { + // Unreachable today — verification accepts the served key before this path + // runs, so it is well-formed by construction. Asserted anyway, because that + // is a property of the current call order rather than of the recorder, and + // the guard is what keeps a future reordering from publishing a body here. + const body = 'UNIQUE-SERVED-BODY/../x'; + const { recordKeyMismatch } = await import('../skills-core.js'); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let calls: unknown[][] = []; + try { + recordKeyMismatch('asked-for', body); + } finally { + calls = [...spy.mock.calls]; + spy.mockRestore(); + } + + const line = String(calls[0][0]); + const record = JSON.parse(line.slice(line.indexOf('{'))) as Record; + expect(record.served_key).toBe(''); + expect(line).not.toContain(body); + }); + + it('redacts a hostile key, body and all', async () => { + // The key comes off the wire, so it gets the same shape-check-then-redact + // treatment as the signal — the body must not reach the log line either. + const body = 'UNIQUE-SECRET-BODY-VIA-BOTH/../x'; + _setStore(new DictStore({ [body]: { key: body, version: 1, content: body, contentHash: body } })); + + const [{ line, record }] = await logged(() => getSkill(body)); + + expect(record.skill_key).toBe(''); + expect(line).not.toContain(body); + }); + + it('redacts a non-sha256 expected hash while still reporting the failure', async () => { + const raw = rawSkill({ key: 'a' }); + raw.contentHash = 'not-a-digest'; + _setStore(new DictStore({ a: raw })); + + const [{ record }] = await logged(() => getSkill('a')); + + expect(record.expected_hash).toBe(''); + expect(record.reason_code).toBe('hash_mismatch'); + }); + + it('omits observed_hash when the failure happened before hashing', async () => { + const raw = rawSkill({ key: 'a' }); + delete raw.content; + _setStore(new DictStore({ a: raw })); + + const [{ record }] = await logged(() => getSkill('a')); + + expect('observed_hash' in record).toBe(false); + expect('expected_hash' in record).toBe(false); + expect(record.version).toBe(1); + }); + + it('carries both hashes on a mismatch — the possible-tampering case', async () => { + _setStore(new DictStore({ a: rawSkill({ key: 'a', contentHash: 'd'.repeat(64) }) })); + + const [{ record }] = await logged(() => getSkill('a')); + + expect(record.expected_hash).toBe('d'.repeat(64)); + expect(record.observed_hash).toBe(hash(SKILL_BODY)); + }); + + it('omits an invalid version rather than emitting it', async () => { + const raw = rawSkill({ key: 'a' }); + raw.version = 0; + _setStore(new DictStore({ a: raw })); + + const [{ record }] = await logged(() => getSkill('a')); + + expect('version' in record).toBe(false); + }); + + it('never emits a null or an empty value in any record', async () => { + // Absent fields are omitted. A null would make a SIEM field mapping + // ambiguous between "not computed" and "computed as nothing". + for (const [, makeStore, run] of cases) { + _clearState(); + _setStore(makeStore()); + const [{ line, record }] = await logged(run); + expect(line).not.toContain('null'); + for (const [key, value] of Object.entries(record)) { + expect(value, key).not.toBeNull(); + expect(value, key).not.toBe(''); + expect(value, key).toBeDefined(); + } + } + }); + + it('never contains the skill content', async () => { + const secret = 'UNIQUE-SECRET-BODY-IN-CONTENT'; + _setStore(new DictStore({ a: rawSkill({ key: 'a', content: secret, contentHash: 'd'.repeat(64) }) })); + + const [{ line }] = await logged(() => getSkill('a')); + + expect(line).not.toContain(secret); + }); + + it('agrees with the signal on every key the two surfaces share', async () => { + // The record's fields are *spread* from the signal's rather than rebuilt, + // and that is the property worth pinning: two independently assembled + // mappings would drift, and the ones most likely to drift are exactly the + // ones that matter — which fields are redacted and which are omitted. So + // this captures both surfaces from one failure and compares them key by key. + // + // A *redacting* failure on purpose: `contentHash` is not a digest, so + // `expected_hash` goes through the replacement branch on its way into both + // surfaces. A well-formed failure would compare equal even against an + // implementation that rebuilt the record by hand. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + _setStore(new DictStore({ a: rawSkill({ key: 'a', version: 7, contentHash: 'not-a-digest' }) })); + + const [{ record }] = await logged(() => getSkill('a')); + const [props] = emitter.signals(INTEGRITY_SIGNAL); + + // Every key the signal carries is a key the record carries — the overlap is + // the signal's whole property set, not an incidental one or two. + expect(Object.keys(props).sort()).toEqual(['expected_hash', 'language', 'observed_hash', 'skill_key', 'version']); + for (const key of Object.keys(props)) expect(record[key], key).toEqual(props[key]); + // Including the redaction, which is the half a rebuilt record loses. + expect(props.expected_hash).toBe(''); + }); + + it('agrees with the signal on which fields are omitted, not only on values', async () => { + // The other half of "spread, not rebuilt": a field neither surface knows has + // to be missing from both. A record assembled separately is exactly where an + // explicit `null` or a stale default creeps in on one side only. + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + const raw = rawSkill({ key: 'a' }); + // No content and an unusable version: nothing was hashed, so neither hash is + // known, and the version cannot be reported either. + delete raw.content; + raw.version = 0; + _setStore(new DictStore({ a: raw })); + + const [{ record }] = await logged(() => getSkill('a')); + const [props] = emitter.signals(INTEGRITY_SIGNAL); + + for (const key of ['version', 'expected_hash', 'observed_hash']) { + expect(key in props, `signal ${key}`).toBe(false); + expect(key in record, `record ${key}`).toBe(false); + } + // And what is left still agrees. + expect(Object.keys(props).sort()).toEqual(['language', 'skill_key']); + for (const key of Object.keys(props)) expect(record[key], key).toEqual(props[key]); + }); + + it('is logged with no emitter configured — telemetry off is not detection off', async () => { + // The reason this record exists: it is the whole detection story for a + // customer whose telemetry is switched off, or who has no destination. + _setStore(new DictStore({ a: rawSkill({ key: 'a', contentHash: 'd'.repeat(64) }) })); + + const records = await logged(() => getSkill('a')); + + expect(records).toHaveLength(1); + expect(records[0].record.reason_code).toBe('hash_mismatch'); + }); +}); + +// ─── contentHash contract ────────────────────────────────────────────── + +describe('contentHash contract', () => { + // The verbatim-bytes rule, checked against the service that computes it. + // + // `contentHash` is specified as sha256, lowercase hex, over the content's + // exact UTF-8 bytes, with no canonicalization on either side — so agreement + // between this SDK and LaunchDarkly's delivery service is part of the rule + // rather than an implementation detail. The SDK cannot verify the service's + // half, but it can pin the one thing that would break if either half began + // normalizing: the digests the service produces for content where a round + // trip is observable. + + /** + * Content whose bytes a normalizing round trip would silently alter: a + * non-ASCII character, a CRLF, and no trailing newline. + */ + // `\u00e9` as an escape rather than the character itself: the input has to be + // exactly the NFC form, and a source file carrying the literal character could + // be re-normalized by an editor without the diff showing anything. + const HAZARD_CONTENT = '# h\u00e9llo\r\n\ntrailing no newline'; + const HAZARD_DIGEST = '2b7c050d94135e5e947263053ebde1c988c2bb90bcc037e8f3d1a2d340b9a558'; + + /** + * Two digests the delivery service computes for the content beside them, + * pinned here as literals. + * + * The literals are the point, and they must never be replaced by a call to + * `hash`. Every other raw object in this file comes from `rawSkill`, which + * hashes its content with the same expression `verifiedBytes` uses, so a + * change to the hashing rule moves the expectation along with it and nothing + * fails. That would leave the one rule which has to agree with a service + * outside this process as the only rule in the feature with no independent + * oracle. These two digests are that oracle. + */ + const serviceVectors: Array<[string, string, string]> = [ + ['ASCII', '# hello', 'ea67f39f2a707e536439ee31e49fdd586b4a8437d3408f0466112d040cd06681'], + ['non-ASCII, CRLF and no trailing newline', HAZARD_CONTENT, HAZARD_DIGEST], + ]; + + /** + * Fixups something between the author and this process might plausibly apply + * to a markdown file: an editor rewriting line endings, a lint step adding the + * final newline, a normalization form decomposing the accent. None of the + * three changes what the document means, and all three change what it hashes + * to — which is the whole reason the rule is stated over bytes. + */ + const normalizations: Array<[string, (text: string) => string]> = [ + ['CRLF rewritten to LF', (text) => text.replace(/\r\n/g, '\n')], + ['a trailing newline appended', (text) => `${text}\n`], + ['NFD decomposition', (text) => text.normalize('NFD')], + ]; + + /** + * The integrity records logged while `run` executed. + * + * `reason_code` lives only on the log record — `recordIntegrityFailure` keeps + * it off the product signal on purpose — so the withholding half has to read + * the record rather than the emitter. + */ + async function integrityRecords(run: () => Promise): Promise>> { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let calls: unknown[][] = []; + try { + await run(); + } finally { + // Read the calls out before restoring: `mockRestore` also resets the + // recorded history, so a read afterwards sees nothing. + calls = [...spy.mock.calls]; + spy.mockRestore(); + } + return calls + .map(([first]) => String(first)) + .filter((line) => line.includes('ld.skills.integrity_failure')) + .map((line) => JSON.parse(line.slice(line.indexOf('{'))) as Record); + } + + it.each(serviceVectors)("verifies the service's pinned digest over %s content", async (_label, content, digest) => { + // Passing means the SDK computes what the service computed. Failing means + // one of the two now normalizes, and a customer would see every skill in + // the environment withheld as `hash_mismatch` with no way to tell why. + const store = new InMemorySkillStore(); + store.put({ key: 'a', version: 1, content, contentHash: digest }); + _setStore(store); + + const verified = await getSkill('a'); + + expect(verified).not.toBeNull(); + // Byte-for-byte, so the digest attests the bytes the caller receives rather + // than some decoded form of them. + expect(verified?.content).toEqual(new TextEncoder().encode(content)); + expect(verified?.contentHash).toBe(digest); + }); + + it.each(normalizations)('withholds the same content with %s', async (_label, normalize) => { + // The teeth on the cases above. Agreeing on a digest proves nothing unless + // disagreeing is detectable, and a rule that quietly tolerated any of these + // three would let content LaunchDarkly never delivered pass verification. + const altered = normalize(HAZARD_CONTENT); + expect(altered, 'the transform left this input alone').not.toBe(HAZARD_CONTENT); + _setStore(new DictStore({ a: { key: 'a', version: 1, content: altered, contentHash: HAZARD_DIGEST } })); + + let listed: Skill[] = []; + const records = await integrityRecords(async () => { + listed = await allSkills(); + }); + + expect(listed).toEqual([]); + expect(records).toHaveLength(1); + expect(records[0].reason_code).toBe('hash_mismatch'); + }); +}); + +// ─── getSkillResult ──────────────────────────────────────────────────── + +describe('getSkillResult', () => { + // The finding this suite covers: `getSkill` returns `null` for four distinct + // outcomes, so a caller cannot fail closed on suspected tampering while + // tolerating a skill nobody configured. These tests pin the distinction, and + // they pin that adding it changed nothing about `getSkill`. + + const TAMPERED_HASH = 'd'.repeat(64); + + /** A store that cannot answer at all — an outage, not an absence. */ + function throwingStore(): SkillStore { + return { + getObject() { + throw new Error('transport failure'); + }, + allObjects() { + throw new Error('transport failure'); + }, + }; + } + + function storeHolding(...raws: RawSkillObject[]): InMemorySkillStore { + const store = new InMemorySkillStore(); + for (const raw of raws) store.put(raw); + return store; + } + + /** + * A store that answers a pinned lookup with some other version. + * + * `wrong_version` is not reachable through `InMemorySkillStore`, which honours + * the pin and answers a miss with `null` — that is `absent`, correctly, because + * the store said it holds nothing for that pin. The reason exists for a store + * that *does* answer and answers with the wrong thing, so the test needs one: + * `resolveFromStore`'s equality check is a defense against an untrusted store, + * not the selection mechanism. + */ + function wrongVersionStore(answeredVersion = 99): SkillStore { + return { + getObject(_kind: string, key: string) { + return rawSkill({ key, version: answeredVersion }); + }, + allObjects() { + return {}; + }, + }; + } + + /** + * One store per reason token. The stores are shaped so each reaches a + * different construction site in `resolveFromStore`, which is what makes the + * mapping — not just the union — the thing under test. + */ + const cases: Array<[experimental.SkillOutcomeReason, () => SkillStore, () => Promise]> = [ + ['ok', () => storeHolding(rawSkill({ key: 'a' })), () => getSkillResult('a')], + ['absent', () => new InMemorySkillStore(), () => getSkillResult('a')], + [ + 'integrity_failure', + () => storeHolding(rawSkill({ key: 'a', contentHash: TAMPERED_HASH })), + () => getSkillResult('a'), + ], + ['store_unavailable', throwingStore, () => getSkillResult('a')], + ['wrong_version', () => wrongVersionStore(3), () => getSkillResult('a', { version: 2 })], + ]; + + it.each(cases)('reports reason %s', async (reason, makeStore, run) => { + _setStore(makeStore()); + + // `wrong_version` writes its log record on the way through, like the + // integrity failures do; silenced here so the assertions below read cleanly. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let outcome: Awaited>; + try { + outcome = (await run()) as Awaited>; + } finally { + spy.mockRestore(); + } + + expect(outcome.reason).toBe(reason); + if (reason === 'ok') { + // `skill` is populated exactly when the reason is `ok`, and `detail` is + // the null that says there is nothing to explain. + expect(outcome.skill).not.toBeNull(); + expect(outcome.skill?.key).toBe('a'); + expect(outcome.detail).toBeNull(); + } else { + expect(outcome.skill).toBeNull(); + // Every failure carries an explanation. An empty string would be a + // reason token with no detail behind it, which is worse than useless to + // whoever is reading the alert. + expect(typeof outcome.detail).toBe('string'); + expect((outcome.detail as string).length).toBeGreaterThan(0); + } + }); + + it('covers the whole reason vocabulary and nothing else', () => { + expect(cases.map(([reason]) => reason).sort()).toEqual([ + 'absent', + 'integrity_failure', + 'ok', + 'store_unavailable', + 'wrong_version', + ]); + }); + + it('distinguishes a store that could not answer from one that answered no', async () => { + // The pair the whole feature exists for on the operational side: an outage + // and an absence must not read the same, or a caller cannot tell "retry or + // page someone" from "this skill was never configured". + _setStore(throwingStore()); + const outage = await getSkillResult('a'); + + _clearState(); + _setStore(new InMemorySkillStore()); + const missing = await getSkillResult('a'); + + expect(outage.reason).toBe('store_unavailable'); + expect(missing.reason).toBe('absent'); + expect(outage.reason).not.toBe(missing.reason); + }); + + it('distinguishes tampering from absence — the fail-closed case', async () => { + _setStore(storeHolding(rawSkill({ key: 'a', contentHash: TAMPERED_HASH }))); + const tampered = await getSkillResult('a'); + + _clearState(); + _setStore(new InMemorySkillStore()); + const missing = await getSkillResult('a'); + + expect(tampered.reason).toBe('integrity_failure'); + expect(missing.reason).toBe('absent'); + // Both are `null` from `getSkill`. That is the defect. + expect(tampered.skill).toBeNull(); + expect(missing.skill).toBeNull(); + }); + + // ── The store seam carries the version ────────────────────────────── + + /** + * A store that genuinely holds two versions of one key and honours the pin. + * + * Inline rather than a rebuilt `InMemorySkillStore`: the point is the seam, and + * multi-version semantics for the bundled store is a separate question. + */ + function twoVersionStore(key: string, versions: number[]): SkillStore { + const held = new Map(versions.map((v) => [v, rawSkill({ key, version: v })])); + return { + getObject(_kind: string, k: string, version?: number | null) { + if (k !== key) return null; + if (version === null || version === undefined) return held.get(Math.max(...held.keys())) ?? null; + return held.get(version) ?? null; + }, + allObjects() { + return {}; + }, + }; + } + + it('resolves a pinned version against a store holding several, not the newest', async () => { + // Without the version threaded into `getObject`, the store answers with + // version 5, the equality check refuses it, and the outcome reports + // `wrong_version` for a pin the store could have satisfied — a wrong reason, + // which is worse than a coarse one. + _setStore(twoVersionStore('a', [2, 5])); + + const pinned = await getSkillResult('a', { version: 2 }); + + expect(pinned.reason).toBe('ok'); + expect(pinned.skill?.version).toBe(2); + }); + + it('an omitted version still means the newest the store holds', async () => { + _setStore(twoVersionStore('a', [2, 5])); + + const newest = await getSkillResult('a'); + + expect(newest.reason).toBe('ok'); + expect(newest.skill?.version).toBe(5); + }); + + it('passes the requested version through to the store lookup', async () => { + const seen: Array = []; + _setStore({ + getObject(_kind: string, _key: string, version?: number | null) { + seen.push(version); + return null; + }, + allObjects() { + return {}; + }, + }); + + await getSkillResult('a', { version: 7 }); + await getSkillResult('a'); + + expect(seen).toEqual([7, null]); + }); + + it('still refuses an answer that is not the version that was asked for', async () => { + // The equality check is a defense, not the selection mechanism. A store that + // ignores the pin — or lies about it — must not get its answer through. + _setStore({ + getObject(_kind: string, _key: string, _version?: number | null) { + return rawSkill({ key: 'a', version: 9 }); + }, + allObjects() { + return {}; + }, + }); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let outcome: Awaited>; + try { + outcome = await getSkillResult('a', { version: 1 }); + } finally { + spy.mockRestore(); + } + + expect(outcome.reason).toBe('wrong_version'); + expect(outcome.skill).toBeNull(); + expect(outcome.detail).toContain('version 1'); + }); + + // ── getSkill is unchanged ─────────────────────────────────────────── + + it('getSkill still resolves to null, and never rejects, for all four failures', async () => { + // The no-behaviour-change guarantee. `getSkill`'s documented contract is + // "resolves to `null` — never rejects", and every existing caller treats + // that `null` as "no skill". Reporting the reason is additive or it is a + // silent breaking change. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + for (const [reason, makeStore] of cases) { + if (reason === 'ok') continue; + _clearState(); + _setStore(makeStore()); + + const wanted = reason === 'wrong_version' ? { version: 2 } : {}; + await expect(getSkill('a', wanted)).resolves.toBeNull(); + } + } finally { + spy.mockRestore(); + } + }); + + it('agrees with getSkill on the skill itself when there is one', async () => { + _setStore(storeHolding(rawSkill({ key: 'a', version: 4 }))); + + const outcome = await getSkillResult('a'); + const direct = await getSkill('a'); + + expect(outcome.skill).toEqual(direct); + }); + + // ── Same single throw as getSkill ─────────────────────────────────── + + it('throws when no store is configured, with the same message as getSkill', async () => { + // "Throws only when no store is configured" is the contract both accessors + // share; a caller switching between them must not have to catch anything new. + const fromResult = await getSkillResult('a').catch((e: unknown) => e as Error); + const fromGetSkill = await getSkill('a').catch((e: unknown) => e as Error); + + expect(fromResult).toBeInstanceOf(Error); + expect(fromResult.message).toBe(fromGetSkill.message); + expect(fromResult.message).toMatch(/setSkillStore/); + }); + + // ── Telemetry and the log record are untouched ─────────────────────── + + it('emits no second integrity record — the failure was already reported', async () => { + // The `ld.skills.integrity_failure` record fires inside verification, before + // the resolution returns. Recording anything here would double-log one + // failure and inflate a customer's alert count. + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + _setStore(storeHolding(rawSkill({ key: 'a', contentHash: TAMPERED_HASH }))); + + let lines: string[] = []; + try { + const outcome = await getSkillResult('a'); + expect(outcome.reason).toBe('integrity_failure'); + } finally { + lines = spy.mock.calls.map(([first]) => String(first)); + spy.mockRestore(); + } + + expect(lines.filter((line) => line.includes('ld.skills.integrity_failure'))).toHaveLength(1); + expect(emitter.signals(INTEGRITY_SIGNAL)).toHaveLength(1); + }); + + it('emits no signal at all for an absent skill', async () => { + const emitter = new RecordingEmitter(); + _setEmitterForTesting(emitter); + _setStore(new InMemorySkillStore()); + + expect((await getSkillResult('a')).reason).toBe('absent'); + + expect(emitter.records).toEqual([]); + }); + + it('detail never contains the skill content', async () => { + // `detail` is documented as safe to surface, so it gets the same treatment + // the log record gets: a hostile store must not be able to route the body + // through it. + const secret = 'UNIQUE-SECRET-BODY-IN-DETAIL'; + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + for (const raw of [ + rawSkill({ key: 'a', content: secret, contentHash: TAMPERED_HASH }), + { key: 'a', version: 1, content: secret, contentHash: secret }, + ]) { + _clearState(); + _setStore(new DictStore({ a: raw as RawSkillObject })); + + const outcome = await getSkillResult('a'); + + expect(outcome.reason).toBe('integrity_failure'); + expect(outcome.detail).not.toContain(secret); + } + } finally { + spy.mockRestore(); + } + }); + + it('reports absent, not wrong_version, for a pin a multi-version store does not hold', async () => { + // The store honours the pin and answers `null`: it has told us it holds + // nothing at that version, which is an absence. `wrong_version` is for a + // store that answers *and answers wrongly* — the two are not the same + // report, and a caller tolerating `absent` should not be handed a + // tampering-shaped token for a version nobody published. + _setStore(storeHolding(rawSkill({ key: 'a', version: 2 }), rawSkill({ key: 'a', version: 5 }))); + + const outcome = await getSkillResult('a', { version: 3 }); + + expect(outcome.reason).toBe('absent'); + expect(outcome.skill).toBeNull(); + }); + + it('detail names the key and both versions on a mismatch, and no path', async () => { + _setStore(wrongVersionStore(3)); + + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + let detail: string | null; + try { + ({ detail } = await getSkillResult('pdf-extraction', { version: 2 })); + } finally { + spy.mockRestore(); + } + + expect(detail).toContain('pdf-extraction'); + expect(detail).toContain('version 2'); + expect(detail).toContain('version 3'); + // Safe to log: no path separator and no root path of any kind. + expect(detail).not.toMatch(/[\\/]/); + expect(detail).not.toContain(process.cwd()); + }); +}); + +// ─── createSkillOutcome ──────────────────────────────────────────────── + +describe('createSkillOutcome', () => { + it('returns a frozen value, like the other value-type factories', () => { + const outcome = createSkillOutcome({ reason: 'absent' }); + + expect(Object.isFrozen(outcome)).toBe(true); + expect(() => { + (outcome as { reason: string }).reason = 'ok'; + }).toThrow(TypeError); + expect(outcome.reason).toBe('absent'); + }); + + it('defaults skill and detail to null', () => { + const outcome = createSkillOutcome({ reason: 'store_unavailable' }); + + expect(outcome.skill).toBeNull(); + expect(outcome.detail).toBeNull(); + }); + + it('carries what it was given', () => { + const built = skill(); + const outcome = createSkillOutcome({ skill: built, reason: 'ok', detail: null }); + + expect(outcome.skill).toBe(built); + expect(outcome.reason).toBe('ok'); + }); +}); diff --git a/packages/client/src/experimental.ts b/packages/client/src/experimental.ts new file mode 100644 index 00000000..219e7ec0 --- /dev/null +++ b/packages/client/src/experimental.ts @@ -0,0 +1,45 @@ +/** + * Experimental features of the LaunchDarkly AI SDK core client. + * + * Import from `@launchdarkly/ai-server/experimental`. Every name here is + * published, but may change in a minor release. None of these names is + * exported from the package root. + * + * Current experimental features: + * + * - **Agent Skills** — version-pinned skill references on AI Configs, verified + * skill content, and materialization to disk. + */ + +// --------------------------------------------------------------------------- +// Agent Skills +// --------------------------------------------------------------------------- + +export { + allSkills, + getSkill, + getSkillResult, + getSkills, + InMemorySkillStore, + setSkillStore, + skillRefs, +} from './skills.js'; +export type { FDv2Mode, FDv2SkillStoreOptions, StoreDiagnostics } from './skills-fdv2.js'; +export { FDv2SkillStore, SKILLS_DEFAULT_BASE_URI, SKILLS_DEFAULT_STREAM_URI } from './skills-fdv2.js'; +export type { WriteSkillsOptions } from './skills-fs.js'; +export { MANIFEST_FILENAME, MANIFEST_VERSION, SKILL_FILENAME, writeSkills } from './skills-fs.js'; +export type { WatchSkillsOptions } from './skills-watch.js'; +export { SKILLS_DEFAULT_DEBOUNCE_MS, SkillWatcher, watchSkills } from './skills-watch.js'; +export type { + OnUnavailable, + RawSkillObject, + ReconcileAction, + ReconcileActionKind, + ReconcileReport, + Skill, + SkillOutcome, + SkillOutcomeReason, + SkillReference, + SkillStore, +} from './types.js'; +export { createSkill, createSkillOutcome, createSkillReference } from './types.js'; diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d022a81d..cfcd442d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -90,7 +90,11 @@ export type { TraverseVisitor, VariationMeta as LDVariationMeta, } from './types.js'; -export { GraphTopologySchema, NATIVE_TOOL_KEY, NativeTool } from './types.js'; +export { + GraphTopologySchema, + NATIVE_TOOL_KEY, + NativeTool, +} from './types.js'; export type { RunUsage, SpanUsage } from './utils.js'; export { addCachedTokensToInput, diff --git a/packages/client/src/lifecycle.ts b/packages/client/src/lifecycle.ts index 41cda129..3d6b11df 100644 --- a/packages/client/src/lifecycle.ts +++ b/packages/client/src/lifecycle.ts @@ -1,5 +1,5 @@ import 'dotenv/config'; -import { trace } from '@opentelemetry/api'; +import { context, propagation, trace } from '@opentelemetry/api'; import { ConversationIdSpanProcessor } from './conversation.js'; import { describeImportFailure, @@ -10,6 +10,7 @@ import { } from './import-diagnostics.js'; import { flushAiSdkInfo, resetAiSdkInfo } from './sdk-info.js'; import { flushSdkUsage, reportUsage, resetSdkUsage } from './sdk-usage.js'; +import { runShutdownHooks } from './shutdown-hooks.js'; import type { AiConfigRep, InitBaseClientOptions, LDClientInterface, LDContext, VariationMeta } from './types.js'; import { parseAiConfig } from './types.js'; @@ -29,6 +30,34 @@ function env(name: string): string | undefined { // biome-ignore lint/suspicious/noExplicitAny: OTel tracer provider loaded via dynamic import with no static type let tracerProvider: any | null = null; +interface OtelGlobals { + trace: boolean; + context: boolean; + propagation: boolean; +} + +const NO_OTEL_GLOBALS: OtelGlobals = { trace: false, context: false, propagation: false }; + +/** + * Which of OTel's process-global registrations `setupTelemetry` actually took. + * + * Each global is one-shot: a second registration is refused, with an + * "Attempted duplicate registration of API" diag error, until the first is + * disabled. So `shutdownTelemetry` must release them, or an init/shutdown/init + * cycle routes every later span to the provider it just shut down. But it may + * release only these: having built a provider does not mean we own the global, + * since another library may have registered first, and disabling theirs would + * tear down the host application's tracing. + */ +let ownedOtelGlobals: OtelGlobals = NO_OTEL_GLOBALS; + +/** + * Counts `shutdownTelemetry` calls, so a `setupTelemetry` that was still + * awaiting its imports when one ran can tell, and build nothing. At that point + * there was no provider to tear down, so the teardown could not stop it. + */ +let telemetryTeardowns = 0; + const LD_OTEL_PEER_DEPS = [ '@opentelemetry/sdk-trace-node', '@opentelemetry/sdk-trace-base', @@ -44,8 +73,13 @@ const LD_OTEL_PEER_DEPS = [ * The OTel SDK packages are optional peer dependencies loaded via dynamic import. * If any are missing, telemetry is silently disabled and a console.warn is emitted * with the npm install command to enable it. + * + * Resolves to the provider this call built, or `null` when it built none, so a + * failed init can tell whether the current provider is its own to tear down. */ -async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): Promise { +// biome-ignore lint/suspicious/noExplicitAny: OTel tracer provider loaded via dynamic import with no static type +async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): Promise { + const teardownsAtStart = telemetryTeardowns; // biome-ignore lint/suspicious/noExplicitAny: optional OTel peer deps loaded via dynamic import with no static types let NodeTracerProvider: any, // biome-ignore lint/suspicious/noExplicitAny: optional OTel peer deps loaded via dynamic import with no static types @@ -84,9 +118,24 @@ async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): P 'packages are not installed. To enable, run:\n' + ` npm install ${LD_OTEL_PEER_DEPS}`, ); - return; + return null; } + // One provider at a time. A second would be refused the global registration, + // so it would receive no spans while replacing the handle `shutdownTelemetry` + // flushes, leaking the live one. A BYOC call made while an options-path init + // is still in flight reuses that attempt's provider, as OTel itself would; a + // failed init has already torn its own down. + // Checked after the imports, since nothing between here and the assignment + // below awaits. + if (tracerProvider) return null; + + // A teardown ran while the imports were pending: `shutdown()` abandoned the + // init this setup belongs to, or the application stopped telemetry itself. + // Either way, a provider built now would outlive it, and the next init would + // reuse it with this attempt's options (its sdkKey as highlight.project_id). + if (telemetryTeardowns !== teardownsAtStart) return null; + const baseEndpoint = options.otlpEndpoint ?? env('OTEL_EXPORTER_OTLP_ENDPOINT') ?? LD_DEFAULT_OTLP_ENDPOINT; const exporter = new OTLPTraceExporter({ @@ -103,16 +152,37 @@ async function setupTelemetry(options: InitBaseClientOptions, sdkKey: string): P : {}), }); - tracerProvider = new NodeTracerProvider({ + const provider = new NodeTracerProvider({ resource, spanProcessors: [new ConversationIdSpanProcessor(), new BatchSpanProcessor(exporter)], }); - tracerProvider.register({ - contextManager: new AsyncLocalStorageContextManager(), - propagator: new CompositePropagator({ - propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()], - }), - }); + + // What `provider.register()` does, one global at a time, because each setter + // reports whether it took the global and `register()` discards that. + const contextManager = new AsyncLocalStorageContextManager(); + contextManager.enable(); + const owned: OtelGlobals = { + trace: trace.setGlobalTracerProvider(provider), + context: context.setGlobalContextManager(contextManager), + propagation: propagation.setGlobalPropagator( + new CompositePropagator({ + propagators: [new W3CTraceContextPropagator(), new W3CBaggagePropagator()], + }), + ), + }; + if (!owned.context) contextManager.disable(); + if (!owned.trace) { + // biome-ignore lint/suspicious/noConsole: OTel's own refusal goes to its diag logger, which is unset by default + console.warn( + '[LaunchDarkly] An OpenTelemetry tracer provider was already registered by something ' + + "else in this process, so LaunchDarkly's telemetry options are not in effect; spans " + + 'go wherever that provider sends them.', + ); + } + + tracerProvider = provider; + ownedOtelGlobals = owned; + return provider; } /** @@ -139,14 +209,26 @@ export async function waitForTelemetry(timeoutMs = 5000): Promise { } /** - * Flushes and shuts down the OTel tracer provider. + * Flushes and shuts down the OTel tracer provider, then releases the OTel + * globals this SDK registered, so a later `initClient` can register its own. * Must be called before process.exit() to ensure all pending spans are exported. */ export async function shutdownTelemetry(): Promise { resetEsmExternalizationWarning(); - if (tracerProvider) { - await tracerProvider.shutdown(); - tracerProvider = null; + // Counted even with no provider yet: see `telemetryTeardowns`. + telemetryTeardowns++; + const provider = tracerProvider; + const owned = ownedOtelGlobals; + tracerProvider = null; + ownedOtelGlobals = NO_OTEL_GLOBALS; + if (!provider) return; + try { + await provider.shutdown(); + } finally { + // See `ownedOtelGlobals`: only what our own registration took. + if (owned.trace) trace.disable(); + if (owned.context) context.disable(); + if (owned.propagation) propagation.disable(); } } @@ -182,8 +264,28 @@ async function initBaseClient(options: InitBaseClientOptions = {}): Promise { // biome-ignore lint/suspicious/noExplicitAny: @launchdarkly/node-server-sdk loaded via dynamic import let init: any; try { @@ -207,9 +309,20 @@ async function initBaseClient(options: InitBaseClientOptions = {}): Promise = { + sdkKey: true, + baseUri: true, + streamUri: true, + eventsUri: true, + serviceName: true, + environment: true, + otlpEndpoint: true, +}; + +/** + * Warns about any options-bag key `initClient` does not read. A JavaScript + * caller (or one that cast past the type) would otherwise have a misspelt or + * retired option dropped silently. + */ +function warnOnUnknownOptions(options: unknown): void { + if (typeof options !== 'object' || options === null) return; + const unknown = Object.keys(options) + .filter((key) => !Object.hasOwn(INIT_CLIENT_OPTIONS, key)) + .sort(); + if (unknown.length > 0) { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; an ignored option must be visible + console.warn(`[LaunchDarkly] Ignoring unrecognized initClient option(s): ${unknown.join(', ')}`); + } +} + /** * Initializes the LaunchDarkly client. * @@ -234,23 +377,56 @@ function isLDClient(value: unknown): value is LDClientInterface { * * **Overload 2 — pre-initialized client (edge / custom runtimes):** * Pass an already-initialized `LDClientInterface`-compatible client (e.g. from - * `@launchdarkly/vercel-server-sdk`) to bypass the Node SDK entirely. + * `@launchdarkly/vercel-server-sdk`) to bypass the Node SDK entirely. The + * optional second argument is the same options bag as the first overload. + * + * Idempotent: later calls return the existing client and ignore their options. + * + * That idempotency covers overload 2 too: once a client is set, passing a + * *different* pre-initialized client does not swap it, and the second call's + * telemetry options are ignored rather than re-running telemetry setup. Call + * `shutdown()` first to hand the SDK a new client. + * + * A call that rejects caches neither a client nor the failure: a later call + * retries initialization (once `LD_SDK_KEY` is available, say) rather than + * replaying the same rejection. + * + * A call still in flight when `shutdown()` runs, or when a pre-initialized + * client is passed meanwhile, is abandoned: it closes the client it built and + * rejects, rather than replacing whatever client came after it. * * Both overloads return the client instance for further customization. */ -export async function initClient(client: LDClientInterface): Promise; +export async function initClient( + client: LDClientInterface, + options?: InitBaseClientOptions, +): Promise; export async function initClient(options?: InitBaseClientOptions): Promise; export async function initClient( optionsOrClient?: InitBaseClientOptions | LDClientInterface, + clientOptions?: InitBaseClientOptions, ): Promise { const singleton = getSingleton(); + warnOnUnknownOptions(isLDClient(optionsOrClient) ? clientOptions : optionsOrClient); + + // Ahead of *both* init paths, so "a second call returns the existing client + // and every other option is ignored" holds for BYOC as well, as it does in + // the Python SDK's `_resolve_client`. Below the BYOC branch, a repeat + // `initClient(client)` re-ran telemetry setup with the new options, which + // could not take effect, and swapped the stored client. + if (singleton.client) { + flushAiSdkInfo(singleton.client); + flushSdkUsage(singleton.client); + return singleton.client; + } + if (isLDClient(optionsOrClient)) { // Pre-initialized client path (edge / custom runtimes). - // Still run telemetry setup so OTel traces work regardless of which - // LD SDK is providing the client. SDK key is optional here — it's only - // used for the highlight.project_id resource attribute. - await setupTelemetry({}, process.env.LD_SDK_KEY ?? ''); + // Still run telemetry setup (with the caller's options) so OTel traces work + // regardless of which LD SDK provides the client. The SDK key is optional + // here — it's only used for the highlight.project_id resource attribute. + await setupTelemetry(clientOptions ?? {}, clientOptions?.sdkKey ?? process.env.LD_SDK_KEY ?? ''); singleton.client = optionsOrClient; singleton.initPromise = Promise.resolve(optionsOrClient); flushAiSdkInfo(optionsOrClient); @@ -258,18 +434,51 @@ export async function initClient( return optionsOrClient; } - if (singleton.client) { - flushAiSdkInfo(singleton.client); - flushSdkUsage(singleton.client); - return singleton.client; - } - if (!singleton.initPromise) { - singleton.initPromise = initBaseClient(optionsOrClient); + const pending = singleton.initPromise ?? startInit(singleton, optionsOrClient); + let client: LDClientInterface; + try { + client = await pending; + } catch (err) { + // A rejection is not cached, so a later call retries — with a key that is + // now set, say — instead of replaying this failure for the life of the + // process. Concurrent waiters on the same attempt all land here; clear it + // only if nothing (a BYOC call, `shutdown()`) has replaced it meanwhile. + if (singleton.initPromise === pending) singleton.initPromise = null; + throw err; } - singleton.client = await singleton.initPromise; - flushAiSdkInfo(singleton.client); - flushSdkUsage(singleton.client); - return singleton.client; + flushAiSdkInfo(client); + flushSdkUsage(client); + return client; +} + +/** + * Starts an options-path init attempt and records it as the in-flight one. + * + * The attempt adopts its client itself, once, rather than each waiter doing so: + * the check that it is still the current attempt and the assignment then run in + * the same synchronous step, with no await between them for `shutdown()` to + * land in. + */ +function startInit(singleton: Singleton, options?: InitBaseClientOptions): Promise { + const attempt: Promise = initBaseClient(options).then(async (client) => { + if (singleton.initPromise !== attempt) { + // `shutdown()` or a BYOC call replaced this attempt while it was in + // flight. Adopting its client would clobber whatever came after and leak + // that client's connection, so close this one instead. Its telemetry is + // not ours to tear down here: `shutdown()` already did, or the BYOC call + // reused the provider. + try { + await client.close(); + } catch { + // The abandonment is the error worth reporting. + } + throw new Error('[LaunchDarkly] initClient was abandoned: shutdown() or another initClient() replaced it.'); + } + singleton.client = client; + return client; + }); + singleton.initPromise = attempt; + return attempt; } export function getClient(): LDClientInterface { @@ -280,15 +489,24 @@ export function getClient(): LDClientInterface { export async function shutdown(): Promise { const singleton = getSingleton(); - if (!singleton.client) return; + // Experimental state (e.g. an Agent Skills store) can exist without a client, + // and an init can be in flight without one, so shutdown() always leaves the + // next initClient() starting from scratch. Dropping the init promise abandons + // that attempt: it closes its own client when it finishes (see `startInit`). + runShutdownHooks(); + singleton.initPromise = null; // Null the singleton before teardown so that any failure mid-flight still // leaves the process in a state where a second shutdown() call is a no-op. const client = singleton.client; singleton.client = null; - singleton.initPromise = null; - resetAiSdkInfo(); - resetSdkUsage(); + if (client) { + resetAiSdkInfo(); + resetSdkUsage(); + } + // With or without a client: an in-flight init has already built its + // provider, and the next init would otherwise reuse it with the old options. await shutdownTelemetry(); + if (!client) return; try { await client.flush(); } finally { diff --git a/packages/client/src/safe-fs.ts b/packages/client/src/safe-fs.ts new file mode 100644 index 00000000..79cb4eb0 --- /dev/null +++ b/packages/client/src/safe-fs.ts @@ -0,0 +1,340 @@ +/** + * Symlink-refusing filesystem primitives for writing files under a directory + * another process may be racing to replace. + * + * A path check is only as good as the last path resolution after it. Which of + * the two implementations runs is a platform property: + * + * - **Linux — the swap window is closed.** A directory is pinned to a descriptor + * and its children are addressed as `/proc/self/fd//`, which the + * kernel resolves from the pinned inode, not the name. Gated on + * {@link SUPPORTS_PROC_FD}. + * - **Everywhere else — the window is narrowed, not closed.** Directories are + * opened with `O_NOFOLLOW`, temp files are created exclusively in the target's + * own directory, and the pinned directory's identity is re-checked just before + * each destructive step. + * + * Windows is not a supported platform: reparse-point checks are not implemented. + * + * Off the Linux fast path, write permission on the managed root **or any of its + * ancestors** is the security boundary; the README's privilege-separated + * deployment (reconcile identity separate from agent identity) is the mitigation. + */ + +import { randomBytes } from 'node:crypto'; +import { closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, statSync } from 'node:fs'; +import * as fsPromises from 'node:fs/promises'; +import { type FileHandle, lstat, mkdir, open, rename, unlink } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +/** Mode set explicitly on every written file: never from the umask, never executable. */ +const FILE_MODE = 0o644; + +/** + * Permission bits set explicitly on every directory this module creates, because `mkdir`'s + * mode argument is masked by the umask: under `0077` a separate agent identity + * could not traverse the directory to read what is inside. + */ +const DIRECTORY_MODE = 0o755; + +/** The setgid bit. Not in `fs.constants`, which exposes no special mode bits. */ +const S_ISGID = 0o2000; + +/** Bound on the `O_EXCL` retry loop; with 64-bit random names it only keeps the loop finite. */ +const TEMP_NAME_ATTEMPTS = 5; + +/** The `*at()` members a descriptor-relative implementation would need. */ +const AT_FAMILY = ['renameat', 'unlinkat', 'openat'] as const; + +/** + * Whether this runtime offers descriptor-relative rename, unlink and open (the + * `*at()` syscall family), which would close the symlink-swap window. + * + * `false` on every Node release to date. Off {@link SUPPORTS_PROC_FD}, that + * leaves a residual race: someone with write permission on the managed root can + * swap a validated directory for a symlink between the identity check and the + * path-based operation. Probed rather than hardcoded so a descriptor-relative + * path can be added if Node ships the family. + */ +export const SUPPORTS_DIR_FD: boolean = (() => { + // Spread rather than index the namespace: bundlers and module proxies reject + // reading an absent export, while a spread only enumerates the ones that exist. + const exported: Record = { ...fsPromises }; + return AT_FAMILY.every((name) => typeof exported[name] === 'function'); +})(); + +/** procfs's per-process descriptor table, where each open fd appears as a magic symlink. */ +const PROC_SELF_FD = '/proc/self/fd'; + +/** + * Whether children can be addressed through a held descriptor as + * `/proc/self/fd//`. + * + * The kernel resolves `/proc/self/fd/` to the inode the descriptor holds, + * so renaming or symlinking the directory afterwards cannot redirect the + * operation — the same guarantee as an `*at()` call. + * + * `false` off Linux (macOS's `/dev/fd/` is not traversable) and on Linux + * without a mounted `/proc`. Probed for real, checking that the entry is a magic + * symlink resolving to the pinned inode, because the swap-race tests are gated + * on it. + */ +export const SUPPORTS_PROC_FD: boolean = (() => { + if (process.platform !== 'linux') return false; + let fd: number | null = null; + try { + fd = openSync(tmpdir(), fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0)); + if (!lstatSync(`${PROC_SELF_FD}/${fd}`).isSymbolicLink()) return false; + const pinned = fstatSync(fd, { bigint: true }); + const resolved = statSync(`${PROC_SELF_FD}/${fd}`, { bigint: true }); + return resolved.dev === pinned.dev && resolved.ino === pinned.ino; + } catch { + return false; + } finally { + if (fd !== null) closeSync(fd); + } +})(); + +/** + * The prefix to build child paths on for a directory this process holds open: + * the descriptor address on the fast path, `realPath` otherwise. + * + * For the filesystem only — never show it in a report or error message. + */ +export function directoryAddress(handle: FileHandle, realPath: string): string { + return SUPPORTS_PROC_FD ? `${PROC_SELF_FD}/${handle.fd}` : realPath; +} + +/** + * Whether `directory` is exactly the {@link directoryAddress} for `handle`. + * + * Exact equality, because a `true` here skips the identity re-check in + * {@link assertUnswapped}. Any other `/proc/self/fd` path (another descriptor, + * a child, a trailing slash, `..`) throws as a caller error. Exported for tests; + * not API. + */ +export function isDescriptorAddressed(directory: string, handle: FileHandle): boolean { + if (directory === `${PROC_SELF_FD}/${handle.fd}`) return true; + if (directory === PROC_SELF_FD || directory.startsWith(`${PROC_SELF_FD}/`)) { + throw new Error( + `a descriptor address must be exactly ${PROC_SELF_FD}/ for the pinned handle; got ${JSON.stringify(directory)}`, + ); + } + return false; +} + +/** + * Refuses a `name` that is not a single path component. A separator, `.` or + * `..` would escape the pinned directory. + */ +function assertSingleComponent(name: string): void { + if (name === '' || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw new Error(`name must be a single path component, got ${JSON.stringify(name)}`); + } +} + +/** + * The destructive filesystem operations, as a replaceable record so tests can + * intercept them (`vi.spyOn` cannot replace module-local calls under Vite's ESM + * transform). + */ +export const fsOps = { + rename(src: string, dst: string): Promise { + return rename(src, dst); + }, + unlink(target: string): Promise { + return unlink(target); + }, +}; + +/** `(dev, ino)` — a directory's identity, independent of its name. */ +export type DirectoryIdentity = { dev: number; ino: bigint }; + +async function identityOf(handle: FileHandle): Promise { + const info = await handle.stat({ bigint: true }); + return { dev: Number(info.dev), ino: info.ino }; +} + +/** + * Opens `directory` without following a final symlink, and pins it. + * + * `O_NOFOLLOW` refuses a symlink at open time. The explicit `isDirectory` check + * covers platforms without `O_DIRECTORY`. + * + * Throws when the path will not open as a real directory. + */ +export async function openDirectoryNoFollow(directory: string): Promise { + const flags = fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0); + let handle: FileHandle; + try { + handle = await open(directory, flags); + } catch (error) { + throw new Error( + `the directory could not be opened without following links: ${error instanceof Error ? error.message : String(error)}`, + ); + } + try { + if (!(await handle.stat()).isDirectory()) throw new Error('the path is not a directory'); + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } + return handle; +} + +/** + * Creates `directory` if absent and returns a handle pinned to it. + * + * Uses a plain `mkdir` plus an `lstat` on `EEXIST`, because + * `mkdir(..., { recursive: true })` accepts an existing symlink-to-directory. + */ +export async function openOrCreateDirectory(directory: string): Promise { + let created = false; + try { + await mkdir(directory, DIRECTORY_MODE); + created = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const info = await lstat(directory); + if (info.isSymbolicLink()) throw new Error('the directory is a symlink'); + if (!info.isDirectory()) throw new Error('the path is not a directory'); + } + const handle = await openDirectoryNoFollow(directory); + // Only a directory this call created: an existing one keeps the mode its + // owner gave it. Set on the handle, never chmod on the path, which a swap + // between mkdir and open could redirect. + if (created) { + try { + // Keep the setgid bit Linux copies from a setgid parent: fchmod sets + // exactly the bits given, so a bare 0755 would clear it and files + // written inside would stop inheriting the shared group. + const inherited = (await handle.stat()).mode & S_ISGID; + await handle.chmod(inherited | DIRECTORY_MODE); + } catch (error) { + await handle.close().catch(() => undefined); + throw error; + } + } + return handle; +} + +/** + * Confirms `directory` still resolves to the inode `handle` was pinned to. + * + * Off {@link SUPPORTS_PROC_FD} this narrows the swap window to the gap before + * the next path-based operation; it cannot close it. + * + * Skipped for a {@link directoryAddress} on the fast path: the kernel already + * resolves from the pinned inode, and `lstat` of `/proc/self/fd/` reports a + * symlink, so the check would fail anyway. + */ +async function assertUnswapped(directory: string, handle: FileHandle): Promise { + if (isDescriptorAddressed(directory, handle)) return; + const pinned = await identityOf(handle); + const onDisk = await lstat(directory, { bigint: true }); + if (!onDisk.isDirectory() || Number(onDisk.dev) !== pinned.dev || onDisk.ino !== pinned.ino) { + throw new Error('the directory was replaced while it was being written to'); + } +} + +/** Random bytes behind every temp name. Hex-encoded, so twice this many characters. */ +const TEMP_NAME_RANDOM_BYTES = 8; + +/** An unpredictable temp name, so a planted path is never the one we write. */ +function tempName(target: string): string { + return `.${target}.${randomBytes(TEMP_NAME_RANDOM_BYTES).toString('hex')}.tmp`; +} + +/** + * Matches exactly the names {@link tempName} produces for `target`, anchored at + * both ends. + * + * The orphan sweep in `skills-fs.ts` uses this to decide what it may delete, so + * it is derived from the generator rather than copied. Anchoring keeps it from + * matching e.g. `.SKILL.md..tmp.keep-this`. + */ +export function tempNamePattern(target: string): RegExp { + const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^\\.${escaped}\\.[0-9a-f]{${TEMP_NAME_RANDOM_BYTES * 2}}\\.tmp$`); +} + +/** + * Writes `data` to `/` so no partial file is ever observable. + * + * The temp file is created exclusively in the target's own directory (so the + * rename is not cross-device), written, fsynced, renamed over the target, and + * the directory fsynced so the rename survives a crash. The mode is set on the + * handle, so a swap of the temp path cannot redirect it, and is never executable. + * + * Off {@link SUPPORTS_PROC_FD}, the directory's identity is re-checked before the + * temp file is opened and again before the rename. This narrows the race but + * does not close it. + */ +export async function atomicWrite( + directory: string, + name: string, + data: Uint8Array, + pinned: FileHandle, +): Promise { + assertSingleComponent(name); + const target = path.join(directory, name); + const flags = fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0); + + await assertUnswapped(directory, pinned); + + let temp = ''; + let handle: FileHandle | null = null; + for (let attempt = 0; attempt < TEMP_NAME_ATTEMPTS; attempt += 1) { + const candidate = path.join(directory, tempName(name)); + try { + handle = await open(candidate, flags, 0o600); + temp = candidate; + break; + } catch (error) { + // O_EXCL: an existing temp path is never reused, nor written through. + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + } + if (handle === null) throw new Error('no usable temporary file name was found'); + + try { + try { + await handle.chmod(FILE_MODE); + await handle.writeFile(data); + await handle.sync(); + } finally { + await handle.close(); + } + await assertUnswapped(directory, pinned); + await fsOps.rename(temp, target); + } catch (error) { + await unlink(temp).catch(() => undefined); + throw error; + } + + await fsyncDirectory(pinned); +} + +/** + * Removes `/`, refusing to follow a symlink at `name`. + * + * `unlink` resolves the directory above the name, so a swapped `` + * would otherwise delete an attacker-chosen file. Pass a {@link directoryAddress} + * to close that window; off {@link SUPPORTS_PROC_FD} the identity re-check only + * narrows it. + * + * Throws when `name` is a symlink. + */ +export async function unlinkNoFollow(directory: string, name: string, pinned: FileHandle): Promise { + assertSingleComponent(name); + const target = path.join(directory, name); + if ((await lstat(target)).isSymbolicLink()) throw new Error('the target file is a symlink'); + await assertUnswapped(directory, pinned); + await fsOps.unlink(target); +} + +/** Best effort — not every platform allows fsync on a directory handle. */ +async function fsyncDirectory(handle: FileHandle): Promise { + await handle.sync().catch(() => undefined); +} diff --git a/packages/client/src/shutdown-hooks.ts b/packages/client/src/shutdown-hooks.ts new file mode 100644 index 00000000..4c3df04e --- /dev/null +++ b/packages/client/src/shutdown-hooks.ts @@ -0,0 +1,46 @@ +/** + * Cleanup hooks that `shutdown()` runs. + * + * This is how core reaches experimental state without importing it: an + * experimental module registers its own cleanup when it loads, and `shutdown()` + * runs whatever is registered. The root entry point never imports experimental + * code, so it neither bundles it nor names it. + * + * Hooks live on a `globalThis` symbol slot, so a hook registered through the + * experimental bundle is run by the root bundle's `shutdown()`. + */ + +const SHUTDOWN_HOOKS_KEY = Symbol.for('@launchdarkly/ai-server:shutdown-hooks'); + +function getHooks(): Map void> { + // biome-ignore lint/suspicious/noExplicitAny: symbol-keyed property on globalThis has no typed accessor + const g = globalThis as any; + if (!g[SHUTDOWN_HOOKS_KEY]) { + g[SHUTDOWN_HOOKS_KEY] = new Map(); + } + return g[SHUTDOWN_HOOKS_KEY]; +} + +/** + * Registers `hook` to run on every `shutdown()`, including before a client + * exists. Keyed by `name`, so a second module instance registering the same + * feature replaces the first hook rather than adding another. + */ +export function registerShutdownHook(name: string, hook: () => void): void { + getHooks().set(name, hook); +} + +/** + * Runs every registered hook. Experimental code must not break a core call, so + * a hook that throws is logged and the rest still run. + */ +export function runShutdownHooks(): void { + for (const [name, hook] of getHooks()) { + try { + hook(); + } catch (err) { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; a failed cleanup must be visible + console.warn(`[LaunchDarkly] Clearing ${name} state failed during shutdown; continuing.`, err); + } + } +} diff --git a/packages/client/src/skills-core.ts b/packages/client/src/skills-core.ts new file mode 100644 index 00000000..fadd71d1 --- /dev/null +++ b/packages/client/src/skills-core.ts @@ -0,0 +1,623 @@ +/** + * Agent Skills internals shared by `skills` and `skills-fs`: the configured + * store, telemetry, integrity verification, and store resolution. + * + * Package-internal: nothing here is exported from the package index. This module + * imports neither `skills` nor `skills-fs`. + * + * **Everything a store returns is untrusted.** Key, version, size and content + * hash are revalidated on every pass, and no store-supplied value reaches a + * signal or log line without a shape check. + */ + +import { createHash } from 'node:crypto'; +import { registerShutdownHook } from './shutdown-hooks.js'; +import type { RawSkillObject, Skill, SkillOutcomeReason, SkillReference, SkillStore } from './types.js'; +import { createSkill, isValidSkillKey, isValidSkillVersion } from './types.js'; + +/** + * The kind the SDK passes to `SkillStore.getObject` and `SkillStore.allObjects`. + * A store adapter may map it onto whatever its transport uses. + */ +export const SKILL_OBJECT_KIND = 'skill'; + +/** + * Hard cap on skill content; anything larger is withheld even if its hash matches. + * + * A backstop against absurd input, set well above LaunchDarkly's own limit so that + * limit can grow without this changing. Not the real limit, so do not pre-flight + * skill sizes against it. + */ +export const MAX_SKILL_CONTENT_BYTES = 10 * 1024 * 1024; + +const LANGUAGE = 'typescript'; + +/** + * What a legitimate content hash looks like. Anything else is redacted before it + * reaches telemetry, so a store cannot leak the skill body through `contentHash`. + */ +const SHA256_HEX = /^[0-9a-f]{64}$/; + +const SIGNAL_INTEGRITY_FAILURE = 'AgentControl Skill Integrity Failure'; +const SIGNAL_MATERIALIZED = 'AgentControl Skill Materialized'; +const SIGNAL_REVOKED = 'AgentControl Skill Revoked Received'; + +/** + * Stable event name for the local integrity-failure log record. SIEM rules match + * on it, so it must never be renamed. + */ +const EVENT_INTEGRITY_FAILURE = 'ld.skills.integrity_failure'; + +/** + * The closed, stable `reason_code` vocabulary for integrity failures; detection + * rules can match on these tokens, and every SDK language emits the same ones. + * + * Eight come from `verifyRawSkill` and fire both the log record and the signal. + * `key_mismatch` and `version_mismatch` are detected after verification and fire + * the log record only (see `recordKeyMismatch`). + */ +export type IntegrityReasonCode = + | 'hash_mismatch' + | 'invalid_key' + | 'invalid_version' + | 'key_mismatch' + | 'missing_content' + | 'missing_content_hash' + | 'not_an_object' + | 'not_utf8' + | 'over_size_cap' + | 'version_mismatch'; + +/** + * What the accessors report when no store is configured. Callers match on + * "skill store"; keep that phrase if the wording changes. + */ +export const NO_STORE_MESSAGE = + 'No skill store is configured, so skill content cannot be retrieved. Configure one with ' + + 'setSkillStore(store) from @launchdarkly/ai-server/experimental — FDv2SkillStore receives content from LaunchDarkly, and ' + + 'InMemorySkillStore is available for local development and testing.'; + +// --------------------------------------------------------------------------- +// Telemetry seam +// --------------------------------------------------------------------------- + +/** + * The internal telemetry emitter. The default is a no-op: no skills telemetry + * leaves the process. Signals are still built, so a transport can be installed + * without touching call sites. + */ +type TelemetryEmitter = { + record(signal: string, properties: Record): void; +}; + +const NOOP_EMITTER: TelemetryEmitter = { record: () => undefined }; + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- + +/** + * State lives on a `globalThis` symbol slot rather than in a module variable, so + * that separate module instances of `@launchdarkly/ai-server` (e.g. in a + * workspace with several symlinked copies) share one configured store. + */ +const SKILLS_STATE_KEY = Symbol.for('@launchdarkly/ai-server:skills'); + +type SkillsState = { + store: SkillStore | null; + emitter: TelemetryEmitter | null; +}; + +function state(): SkillsState { + const g = globalThis as Record; + let current = g[SKILLS_STATE_KEY]; + if (!current) { + current = { store: null, emitter: null }; + g[SKILLS_STATE_KEY] = current; + } + return current; +} + +/** Replaces the configured store. Reached through `skills._setStore`. */ +export function setStore(store: SkillStore): void { + state().store = store; +} + +/** Replaces the telemetry emitter. Reached through `skills._setEmitterForTesting`. */ +export function setEmitter(emitter: TelemetryEmitter): void { + state().emitter = emitter; +} + +/** + * Drops both the store and the emitter. Reached through `skills._clearState`, + * and run by `shutdown()` through the hook registered below. + */ +export function clearState(): void { + const current = state(); + current.store = null; + current.emitter = null; +} + +// Registered on load, so core's `shutdown()` clears this state without +// importing any Agent Skills module. +registerShutdownHook('experimental Agent Skills', clearState); + +/** The configured store, or `null`. The only reader of the slot. */ +export function getStore(): SkillStore | null { + return state().store; +} + +export function requireStore(): SkillStore { + const store = getStore(); + if (store === null) throw new Error(NO_STORE_MESSAGE); + return store; +} + +/** + * Whether `store` has received its initial data. + * + * `true` if the store has no `isInitialized()`. A probe that throws counts as not + * initialized. An uninitialized store is reported unavailable, which suppresses + * pruning so a slow start cannot delete managed files. + */ +export function storeIsInitialized(store: SkillStore): boolean { + const probe = (store as { isInitialized?: unknown }).isInitialized; + if (typeof probe !== 'function') return true; + try { + return Boolean(probe.call(store)); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; a failing store must be visible + console.warn( + `[LaunchDarkly] The skill store's isInitialized() threw; treating the store as not yet initialized: ${storeThrew(error)}`, + ); + return false; + } +} + +/** + * Records one signal. Never throws: a broken emitter must not fail a retrieval or + * reconcile. + */ +function emit(signal: string, properties: Record): void { + const emitter = state().emitter ?? NOOP_EMITTER; + try { + emitter.record(signal, properties); + } catch { + // biome-ignore lint/suspicious/noConsole: the seam must not raise, but a broken emitter should still be visible + console.warn('[LaunchDarkly] Skills telemetry emitter threw; ignoring.'); + } +} + +/** + * Records an integrity failure as one local log record and one signal. + * + * Carries hashes and byte counts only, never the skill body. The log record works + * even with telemetry off; it adds the event name, action, reason and + * `reason_code`. Its fields are a documented contract. + */ +export function recordIntegrityFailure( + skillKey: unknown, + reasonCode: IntegrityReasonCode, + reason: string, + extra: { version?: unknown; expectedHash?: unknown; observedHash?: string } = {}, +): void { + // Key and expected hash are store-supplied and could carry the skill body: + // shape-check, then redact, once for both the signal and the log record. + const safeKey = isValidSkillKey(skillKey) ? skillKey : ''; + let safeExpectedHash: string | null = null; + if (typeof extra.expectedHash === 'string') { + safeExpectedHash = SHA256_HEX.test(extra.expectedHash) ? extra.expectedHash : ''; + } + + const properties: Record = { skill_key: safeKey, language: LANGUAGE }; + if (isValidSkillVersion(extra.version)) properties.version = extra.version; + if (safeExpectedHash !== null) properties.expected_hash = safeExpectedHash; + if (extra.observedHash !== undefined) properties.observed_hash = extra.observedHash; + + // `reason_code` is log-record only; signal properties are a fixed allowlist. + // Absent fields are omitted, never null. Keys are inserted in alphabetical + // order so the JSON is identical across SDK languages (apart from `language`). + // Do not reorder. + const record: Record = { action: 'withheld', event: EVENT_INTEGRITY_FAILURE }; + if (safeExpectedHash !== null) record.expected_hash = safeExpectedHash; + record.language = LANGUAGE; + if (extra.observedHash !== undefined) record.observed_hash = extra.observedHash; + record.reason = reason; + record.reason_code = reasonCode; + record.skill_key = safeKey; + if (isValidSkillVersion(extra.version)) record.version = extra.version; + + // Logged as text (for plain consoles and grep) and as a second structured + // argument (for structured-console transports). Both are needed. + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; integrity failures must be visible + console.error(`[LaunchDarkly] ${EVENT_INTEGRITY_FAILURE} ${JSON.stringify(record)}`, record); + emit(SIGNAL_INTEGRITY_FAILURE, properties); +} + +/** + * Records a store answering under a key other than the one requested. + * + * **Log record only, no signal.** A substituted skill may indicate tampering, so + * it is logged under `EVENT_INTEGRITY_FAILURE` for existing SIEM rules to catch. + * The usual cause, though, is a broken store adapter (stale cache, colliding + * key), so it is kept out of product telemetry. `recordVersionMismatch` follows + * the same rule. + * + * Both keys are shape-checked and redacted, regardless of call order. + */ +export function recordKeyMismatch(requested: unknown, served: unknown): void { + const record: Record = { + action: 'withheld', + event: EVENT_INTEGRITY_FAILURE, + language: LANGUAGE, + reason: 'the skill store answered under a different key than the one requested', + reason_code: 'key_mismatch' satisfies IntegrityReasonCode, + // The key the store answered under, for diagnosing the adapter. + served_key: isValidSkillKey(served) ? served : '', + // The key the caller asked for, as on every other record. + skill_key: isValidSkillKey(requested) ? requested : '', + }; + // No hashes or version: verification passed, so neither disqualified it. + // Keys are alphabetical, as in `recordIntegrityFailure`. Do not reorder. + // + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; integrity failures must be visible + console.error(`[LaunchDarkly] ${EVENT_INTEGRITY_FAILURE} ${JSON.stringify(record)}`, record); +} + +/** + * Records a store answering a version pin with a different version. + * + * **Log record only, no signal**, for the reason `recordKeyMismatch` gives. The + * built-in stores answer a pin with that version or `null`, so only a custom + * adapter reaches this. `getSkill` returns `null` for `wrong_version`, so this + * record is what makes it visible. + * + * `served` is shape-checked, which also keeps it an integer in the JSON. + * `requested` is the caller's own pin and is logged as given. + */ +export function recordVersionMismatch(key: unknown, requested: number, served: unknown): void { + const record: Record = { + action: 'withheld', + event: EVENT_INTEGRITY_FAILURE, + language: LANGUAGE, + reason: 'the skill store answered with a different version than the one requested', + reason_code: 'version_mismatch' satisfies IntegrityReasonCode, + // The version the store answered with, for diagnosing the adapter. + served_version: isValidSkillVersion(served) ? served : '', + skill_key: isValidSkillKey(key) ? key : '', + // The version requested, as `version` means on every other record. + version: requested, + }; + // No hashes: verification passed, so they did not disqualify it. + // Keys are alphabetical, as in `recordIntegrityFailure`. Do not reorder. + // + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; integrity failures must be visible + console.error(`[LaunchDarkly] ${EVENT_INTEGRITY_FAILURE} ${JSON.stringify(record)}`, record); +} + +/** + * Records a materialization. Carries no filesystem path; paths are reported in + * the returned `ReconcileReport` instead. + */ +export function recordMaterialized( + skillKey: string, + contentBytes: number, + contentHash: string, + reconcileAction: string, +): void { + emit(SIGNAL_MATERIALIZED, { + skill_key: skillKey, + content_bytes: contentBytes, + content_hash: contentHash, + reconcile_action: reconcileAction, + language: LANGUAGE, + }); +} + +/** Records a revocation: a prune that removed a formerly managed skill. */ +export function recordRevoked(skillKey: unknown, version: unknown): void { + // Both fields come from the manifest, which is untrusted: shape-check, then + // redact. + const safeKey = isValidSkillKey(skillKey) ? skillKey : ''; + const properties: Record = { + skill_key: safeKey, + removed_from_disk: true, + language: LANGUAGE, + }; + if (isValidSkillVersion(version)) properties.version = version; + emit(SIGNAL_REVOKED, properties); +} + +// --------------------------------------------------------------------------- +// Integrity verification +// --------------------------------------------------------------------------- + +/** Content that passed integrity verification. */ +export type VerifiedContent = { + /** The verbatim bytes, exactly as hashed. */ + readonly encoded: Uint8Array; + /** The locally computed sha256 — never the caller's expected value. */ + readonly contentHash: string; +}; + +/** Why content did not pass. The reason is safe to show a caller. */ +export type VerificationFailure = { readonly reason: string }; + +export function isVerificationFailure(result: VerifiedContent | VerificationFailure): result is VerificationFailure { + return 'reason' in result; +} + +/** + * Verifies content: size cap, then UTF-8 encoding, then sha256 hash. + * + * The fixed order means content failing several checks always reports the same + * `reason_code`. A `string` (from the wire) is UTF-8 encoded here; a `Uint8Array` + * (a `Skill.content`) is copied before hashing, because a `Skill`'s bytes can be + * mutated by whoever created it, and the bytes written must be the bytes hashed. + * + * Returns the verbatim bytes and the locally computed hash, or a + * `VerificationFailure` after recording the integrity failure. + * + * Runs at the accessor boundary and again before a write, because callers can + * construct a `Skill` directly. Do not skip the second pass. + */ +export function verifiedBytes( + key: string, + content: string | Uint8Array, + expectedHash: string, + version: number, +): VerifiedContent | VerificationFailure { + // `new Uint8Array(content)`, not `content.slice()`: `Buffer.prototype.slice` + // returns a view over the same memory rather than a copy. + const encoded = typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content); + + if (encoded.byteLength > MAX_SKILL_CONTENT_BYTES) { + const reason = `content is ${encoded.byteLength} bytes, over the ${MAX_SKILL_CONTENT_BYTES} byte cap`; + recordIntegrityFailure(key, 'over_size_cap', reason, { version, expectedHash }); + return { reason }; + } + + // A lone surrogate has no UTF-8 encoding; TextEncoder silently substitutes + // U+FFFD, so a decode round-trip detects it before the hash check. + // `ignoreBOM: true` keeps a leading BOM from failing the round-trip. + if (typeof content === 'string' && new TextDecoder('utf-8', { ignoreBOM: true }).decode(encoded) !== content) { + const reason = 'content is not encodable as UTF-8'; + recordIntegrityFailure(key, 'not_utf8', reason, { version, expectedHash }); + return { reason }; + } + + // sha256, lowercase hex, over the verbatim bytes; no canonicalization. + const observedHash = createHash('sha256').update(encoded).digest('hex'); + if (observedHash !== expectedHash) { + recordIntegrityFailure(key, 'hash_mismatch', 'content hash mismatch', { version, expectedHash, observedHash }); + return { reason: 'content hash mismatch' }; + } + + return { encoded, contentHash: observedHash }; +} + +/** + * Turns one untrusted raw store object into a `Skill`, or returns `null`. + * + * On failure, records the integrity failure and logs an error. Unverified + * content is never returned. + */ +export function verifyRawSkill(raw: unknown): Skill | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + recordIntegrityFailure('', 'not_an_object', 'raw skill object is not an object'); + return null; + } + + const candidate = raw as RawSkillObject; + + const { key } = candidate; + if (!isValidSkillKey(key)) { + recordIntegrityFailure(key, 'invalid_key', 'key is not a valid skill key'); + return null; + } + + const { version } = candidate; + if (!isValidSkillVersion(version)) { + recordIntegrityFailure(key, 'invalid_version', 'version is not an integer >= 1'); + return null; + } + + const { content } = candidate; + if (typeof content !== 'string') { + recordIntegrityFailure(key, 'missing_content', 'content is missing or not a string', { version }); + return null; + } + + const expectedHash = candidate.contentHash; + if (typeof expectedHash !== 'string') { + recordIntegrityFailure(key, 'missing_content_hash', 'contentHash is missing or not a string', { version }); + return null; + } + + const verified = verifiedBytes(key, content, expectedHash, version); + if (isVerificationFailure(verified)) return null; + + return createSkill({ + key, + version, + content: verified.encoded, + contentHash: verified.contentHash, + name: typeof candidate.name === 'string' ? candidate.name : null, + description: typeof candidate.description === 'string' ? candidate.description : null, + }); +} + +/** + * Logs one warning per batch when content was withheld, with the counts. + * + * Makes withholding visible, especially when nothing verified and the empty + * result would look like "no skills". + */ +export function logWithholdingSummary(subject: string, requested: number, resolved: number): void { + const withheld = requested - resolved; + if (withheld <= 0) return; + if (resolved === 0) { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; an empty result must not read as "no skills" + console.warn( + `[LaunchDarkly] All ${requested} ${subject} were withheld and no skill content is available. Every ` + + 'object failed verification — check that the delivered objects carry a contentHash matching the ' + + 'sha256 of their content.', + ); + return; + } + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; withheld skills must be visible + console.warn( + `[LaunchDarkly] ${withheld} of ${requested} ${subject} were withheld and are unavailable; see the ` + + 'preceding errors for the per-skill reason.', + ); +} + +/** The one wording for "the store could not answer", used by every path. */ +export function storeThrew(error: unknown): string { + const name = error instanceof Error ? error.constructor.name : 'unknown error'; + const message = error instanceof Error ? error.message : String(error); + return `the skill store threw ${name}: ${message}`; +} + +/** Every raw object the store holds, or the reason it could not answer. */ +export type RawListing = { + readonly objects: Record; + /** `null` when the store answered; otherwise why it could not. */ + readonly error: string | null; +}; + +/** + * Lists every raw object the store holds, or reports why it could not. + * + * One entry per (key, version); use `newestByKey` for one per key. A throwing + * store is caught and reported in `error`. A non-object answer is treated as a + * broken store, not an empty one, so it cannot read downstream as "every skill + * was revoked". + */ +export function allRawObjects(store: SkillStore): RawListing { + let objects: unknown; + try { + objects = store.allObjects(SKILL_OBJECT_KIND); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; a failing store must be visible + console.error(`[LaunchDarkly] Skill store threw while listing skills: ${storeThrew(error)}`); + return { objects: {}, error: storeThrew(error) }; + } + if (typeof objects !== 'object' || objects === null || Array.isArray(objects)) { + const typeName = Array.isArray(objects) ? 'array' : objects === null ? 'null' : typeof objects; + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; a failing store must be visible + console.error(`[LaunchDarkly] Skill store listed skills as ${typeName} rather than an object`); + return { objects: {}, error: `the skill store listed skills as ${typeName} rather than an object` }; + } + return { objects: objects as Record, error: null }; +} + +/** One raw object, paired with the store key it was served under. */ +export type ServedObject = { + /** The store's own map key — opaque, and only a fallback identity. */ + readonly objectKey: string; + readonly raw: RawSkillObject; +}; + +/** + * The highest version of each skill key, paired with its store key (used to + * attribute failures when the object's own key is unusable). + * + * Objects without a usable key and version are **kept** so verification reports + * them; dropping them would let prune delete the last good copy on disk. They are + * dropped only when their key resolved from another version. + */ +export function newestByKey(objects: Record): ServedObject[] { + // The validated version is kept beside the object so the comparison needs no cast. + const best = new Map(); + const unusable: ServedObject[] = []; + for (const [objectKey, raw] of Object.entries(objects)) { + const key = typeof raw === 'object' && raw !== null ? raw.key : undefined; + const version = typeof raw === 'object' && raw !== null ? raw.version : undefined; + if (!isValidSkillKey(key) || !isValidSkillVersion(version)) { + unusable.push({ objectKey, raw }); + continue; + } + const held = best.get(key); + if (held === undefined || version > held.version) best.set(key, { served: { objectKey, raw }, version }); + } + const withheld = unusable.filter(({ raw }) => { + const key = typeof raw === 'object' && raw !== null ? raw.key : undefined; + return !(isValidSkillKey(key) && best.has(key)); + }); + return [...[...best.values()].map(({ served }) => served), ...withheld]; +} + +// --------------------------------------------------------------------------- +// Resolution internals — shared with the materialization path +// --------------------------------------------------------------------------- + +/** One key resolved against a store: the skill, or why there is none. */ +export type Resolution = { + readonly skill?: Skill | null; + readonly error?: string | null; + /** + * Which of the five public outcomes this is; `getSkillResult` publishes it. + * Required, so every construction site must choose one. + */ + readonly reason: SkillOutcomeReason; + /** + * `true` when the store could not answer, rather than answered "no"; set + * exactly when `reason` is `store_unavailable`. Suppresses pruning, so an + * outage cannot delete managed files. + */ + readonly unavailable?: boolean; +}; + +/** + * Fetches one key from `store` and verifies it. + * + * `wantedVersion` is passed to the store (`null` for the newest). Because the + * store is untrusted, an answer with a different key or version is still + * withheld. + */ +export function resolveFromStore(store: SkillStore, key: string, wantedVersion: number | null): Resolution { + let raw: RawSkillObject | null | undefined; + try { + raw = store.getObject(SKILL_OBJECT_KIND, key, wantedVersion); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; a failing store must be visible + console.error(`[LaunchDarkly] Skill store threw while retrieving '${key}': ${storeThrew(error)}`); + return { error: storeThrew(error), reason: 'store_unavailable', unavailable: true }; + } + + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return { error: `skill '${key}' is not available from the configured skill store`, reason: 'absent' }; + } + + const skill = verifyRawSkill(raw); + if (skill === null) { + return { error: `skill '${key}' failed integrity verification and was withheld`, reason: 'integrity_failure' }; + } + if (skill.key !== key) { + // `integrity_failure`, not `absent`: a substituted skill is something + // callers should fail closed on. Log record only; see `recordKeyMismatch`. + recordKeyMismatch(key, skill.key); + return { + error: `skill '${key}' is not available: the store answered under key '${skill.key}'`, + reason: 'integrity_failure', + }; + } + if (wantedVersion !== null && skill.version !== wantedVersion) { + // Log record only; see `recordVersionMismatch`. + recordVersionMismatch(key, wantedVersion, skill.version); + return { + error: `skill '${key}' version ${wantedVersion} is not available (the store holds version ${skill.version})`, + reason: 'wrong_version', + }; + } + return { skill, reason: 'ok' }; +} + +/** + * Normalizes a reference or bare key into `[key, wanted version]`; a bare key + * wants the newest version. + */ +export function referenceTarget(item: SkillReference | string): [string, number | null] { + return typeof item === 'string' ? [item, null] : [item.key, item.version]; +} diff --git a/packages/client/src/skills-fdv2.ts b/packages/client/src/skills-fdv2.ts new file mode 100644 index 00000000..fda52ba8 --- /dev/null +++ b/packages/client/src/skills-fdv2.ts @@ -0,0 +1,2044 @@ +/** + * Agent Skills — the FDv2 delivery transport. + * + * `FDv2SkillStore` is the `SkillStore` that receives skills from LaunchDarkly + * over `GET /sdk/poll` or `GET /sdk/stream`, authenticated with the + * environment's server-side SDK key. It holds raw wire objects and serves them to + * the accessors; it uses only platform globals (`fetch`, `AbortController`, + * `TextDecoder`). + * + * What it does not do: + * + * - **Verify content.** Integrity verification happens at the accessor boundary, + * so it applies to every store, including one you supply yourself. + * - **Work around a missing `contentHash`.** Such an object is held as-is and + * then withheld by verification with `missing_content_hash`; the store logs an + * error and counts it in {@link StoreDiagnostics.hashlessObjects}. + * - **Evaluate flags.** Non-skill objects are skipped and counted. + */ + +import { SKILL_OBJECT_KIND } from './skills-core.js'; +import type { RawSkillObject, SkillStore } from './types.js'; +import { isValidSkillVersion } from './types.js'; + +// --------------------------------------------------------------------------- +// The wire contract +// --------------------------------------------------------------------------- + +/** + * The FDv2 object `kind` of a skill (exact, lower-case match). + * + * Distinct from {@link FDV2_PAYLOAD_KIND}, the kind of the payload skills arrive in. + */ +export const FDV2_OBJECT_KIND = 'skill'; + +/** + * The FDv2 payload kind declared on every request as `?kinds=`. + * + * Required: a request without it is served the flag payload and no skills. + */ +export const FDV2_PAYLOAD_KIND = 'agent-skill'; + +/** + * Separates key from version in a skill object's wire `key` + * (`:`). Each version of a skill is a separate object. + */ +export const FDV2_KEY_DELIMITER = ':'; + +/** + * Where `GET /sdk/poll` is served. Override for Federal, private, or relay + * deployments. + */ +export const SKILLS_DEFAULT_BASE_URI = 'https://sdk.launchdarkly.com'; + +/** + * Where `GET /sdk/stream` is served. LaunchDarkly streams from a different host + * than it polls from; a `baseUri` given without a `streamUri` is used for both. + */ +export const SKILLS_DEFAULT_STREAM_URI = 'https://stream.launchdarkly.com'; + +export const POLL_PATH = '/sdk/poll'; +export const STREAM_PATH = '/sdk/stream'; + +/** Default `readTimeoutSeconds` in `'poll'` mode: the bound on one whole request. */ +export const DEFAULT_POLL_TIMEOUT_SECONDS = 10; + +/** + * Default `readTimeoutSeconds` in `'stream'` mode: the longest gap tolerated + * between two reads. LaunchDarkly's heartbeats arrive well inside this. + */ +export const DEFAULT_STREAM_READ_TIMEOUT_SECONDS = 300; + +/** + * Seconds as whole milliseconds, for `setTimeout` and the read deadline. Every + * time option is in seconds; this is the one place they become milliseconds. + * + * Rounded, not truncated: `1.005 * 1000` is `1004.9999999999999`, so truncating + * would take a millisecond off a value the caller wrote exactly. `minimum` is 1 + * for an option that must be positive, so a sub-millisecond value never becomes + * a zero delay. Capped at 2^31−1, the longest delay `setTimeout` honors; past + * that Node fires the timer after 1ms, and a poll interval would spin. + */ +const MAX_TIMEOUT_MS = 2 ** 31 - 1; + +export function secondsToMs(seconds: number, minimum = 0): number { + return Math.min(MAX_TIMEOUT_MS, Math.max(minimum, Math.round(seconds * 1000))); +} + +const EVENT_SERVER_INTENT = 'server-intent'; +const EVENT_PUT_OBJECT = 'put-object'; +const EVENT_DELETE_OBJECT = 'delete-object'; +const EVENT_PAYLOAD_TRANSFERRED = 'payload-transferred'; +const EVENT_HEARTBEAT = 'heart-beat'; +const EVENT_GOODBYE = 'goodbye'; +const EVENT_ERROR = 'error'; + +// The events whose data `ProtocolReader.handle` reads. Only these are parsed, so a +// `heart-beat` or an unknown event with data that is not JSON is ignored, as +// the base SDK's `PayloadStreamReader` ignores an event it has no handler for. +const EVENTS_WITH_DATA: ReadonlySet = new Set([ + EVENT_SERVER_INTENT, + EVENT_PUT_OBJECT, + EVENT_DELETE_OBJECT, + EVENT_PAYLOAD_TRANSFERRED, + EVENT_GOODBYE, + EVENT_ERROR, +]); + +const INTENT_TRANSFER_FULL = 'xfer-full'; +const INTENT_TRANSFER_CHANGES = 'xfer-changes'; +const INTENT_TRANSFER_NONE = 'none'; + +/** + * Envelope fields copied verbatim. Never coerced or defaulted: filling in a + * missing field would forge what verification checks. + */ +const ENVELOPE_FIELDS = ['contentType', 'content', 'contentHash', 'name', 'description'] as const; + +/** + * The payload id inside a transfer's selector, `(p::)`. Used when + * the intent named no `id`. + */ +const PAYLOAD_SELECTOR = /\(p:([^:()]+):\d+\)/; + +export type FDv2Mode = 'stream' | 'poll'; + +const MOBILE_KEY_PREFIX = 'mob-'; +const SERVER_KEY_PREFIX = 'sdk-'; + +/** A client-side environment ID: unprefixed lowercase hex. */ +const CLIENT_SIDE_ID = /^[0-9a-f]{20,}$/; + +function debug(message: string): void { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; routine events log at debug + console.debug(`[LaunchDarkly] ${message}`); +} + +function warn(message: string): void { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; delivery problems must be visible + console.warn(`[LaunchDarkly] ${message}`); +} + +function error(message: string): void { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; delivery problems must be visible + console.error(`[LaunchDarkly] ${message}`); +} + +// --------------------------------------------------------------------------- +// Server-side only +// --------------------------------------------------------------------------- + +/** + * Throws for a missing key, a mobile key, or a client-side environment ID; + * returns the key trimmed. + * + * Skill content is confidential, and the server may not refuse a client-side + * credential itself, so the SDK does. + */ +export function requireServerSideCredential(sdkKey: unknown): string { + if (typeof sdkKey !== 'string' || sdkKey.trim() === '') { + throw new Error('FDv2SkillStore requires a LaunchDarkly server-side SDK key (sdk-...); none was given.'); + } + const key = sdkKey.trim(); + if (key.startsWith(MOBILE_KEY_PREFIX)) { + throw new Error( + 'FDv2SkillStore was given a mobile key (mob-...). Agent Skills are a server-side feature: skill content is ' + + "customer-confidential and is never delivered to a mobile or client-side process. Use the environment's " + + 'server-side SDK key (sdk-...).', + ); + } + if (CLIENT_SIDE_ID.test(key)) { + throw new Error( + 'FDv2SkillStore was given what looks like a client-side environment ID. Agent Skills are a server-side ' + + 'feature: skill content is customer-confidential and is never delivered to a client-side process. Use the ' + + "environment's server-side SDK key (sdk-...).", + ); + } + if (!key.startsWith(SERVER_KEY_PREFIX)) { + // Warn only: private instances and test doubles may use unprefixed keys. + warn( + 'The credential given to FDv2SkillStore does not look like a LaunchDarkly server-side SDK key (sdk-...). ' + + 'Skills are delivered only to server-side credentials; if this is a client-side or mobile credential the ' + + 'connection will be rejected or will deliver nothing.', + ); + } + return key; +} + +/** The only hosts a plain `http://` URI may name: a local test double. */ +const LOOPBACK_HOSTS: ReadonlySet = new Set(['localhost', '127.0.0.1', '::1']); + +/** + * Throws for a URI that would send the SDK key in cleartext; returns it trimmed. + * + * Requires `https://` with a host; plain `http://` is allowed only to a loopback + * host (`localhost`, `127.0.0.1`, `::1`) for local test doubles. Applies even + * when a custom `requester` is supplied. + */ +export function requireHttpsUri(uri: unknown, option: 'baseUri' | 'streamUri' = 'baseUri'): string { + if (typeof uri !== 'string' || uri.trim() === '') { + throw new Error(`FDv2SkillStore requires an https:// URI for ${option}; none was given.`); + } + const trimmed = uri.trim(); + let parsed: URL | null = null; + try { + parsed = new URL(trimmed); + } catch { + parsed = null; + } + // WHATWG `hostname` keeps the brackets on an IPv6 literal; strip them. + const hostname = parsed?.hostname.replace(/^\[(.*)\]$/, '$1') ?? ''; + if (parsed?.protocol === 'https:' && hostname !== '') return trimmed; + if (parsed?.protocol === 'http:' && LOOPBACK_HOSTS.has(hostname)) return trimmed; + if (parsed?.protocol === 'http:') { + throw new Error( + `FDv2SkillStore refuses ${option} ${JSON.stringify(trimmed)}: a plain http:// URI would send the server-side ` + + 'SDK key in cleartext. Use https:// (the defaults are https://sdk.launchdarkly.com for polling and ' + + 'https://stream.launchdarkly.com for streaming). Plain http:// is allowed only for a loopback host ' + + '(localhost, 127.0.0.1, ::1) serving a local test double.', + ); + } + throw new Error( + `FDv2SkillStore refuses ${option} ${JSON.stringify(trimmed)}: expected an https:// URI with a host, such as ` + + 'https://sdk.launchdarkly.com.', + ); +} + +// --------------------------------------------------------------------------- +// Diagnostics +// --------------------------------------------------------------------------- + +/** + * Counters describing what the transport has seen. Read-only. + * + * Useful for telling "this environment has no skills" apart from "every skill + * was withheld". + */ +export type StoreDiagnostics = { + /** Completed `payload-transferred` commits since the store started. */ + readonly payloadsTransferred: number; + /** `put-object` events identified as skills, across all payloads. */ + readonly skillObjectsReceived: number; + /** Objects skipped because they were not skills. */ + readonly objectsIgnored: number; + /** + * Skill revocations: each `delete-object` that removed something, plus each + * key a full transfer dropped entirely (not a key whose version moved). + */ + readonly objectsRevoked: number; + /** + * Transfers not applied because they completed a payload other than the + * skill payload. Normally zero. + */ + readonly payloadsIgnored: number; + /** + * Skill objects whose envelope carried no `contentHash`. + * + * **Nonzero means skills are being withheld** with `missing_content_hash`. + * Cumulative: read it as "this has happened", not as the current count. + */ + readonly hashlessObjects: number; + /** + * Recoverable transport failures in a row; reset by a completed exchange (a + * commit or a `none` intent). A server-initiated `goodbye` after such an + * exchange is not counted, unless it is marked `catastrophe`. + */ + readonly connectionFailures: number; + /** The most recent transport error, if any. Human-readable; do not parse. */ + readonly lastError: string | null; +}; + +const HASHLESS_ADVICE = + "The delivered skill object carries no 'contentHash', so integrity verification withholds it with reason_code " + + "'missing_content_hash' and its content will not resolve. Contact LaunchDarkly support."; + +/** + * Ceiling on remembered hashless reports (oldest evicted first). An evicted + * object may be reported again. + */ +const HASHLESS_MEMORY_LIMIT = 512; + +/** Distinguishes the store-wide summary's entry from a per-object one. */ +const SUMMARY_MARKER = '\u0000summary\u0000'; + +/** + * Which hashless objects (and which store-wide summary) one reader has already + * logged. Per store, so a re-delivered payload is not re-reported and each + * store reports independently. + */ +class HashlessMemory { + readonly seen = new Set(); + + private remember(entry: string): void { + // Insertion-ordered iteration makes the first entry the oldest. + while (this.seen.size >= HASHLESS_MEMORY_LIMIT) { + const oldest = this.seen.values().next().value; + if (oldest === undefined) break; + this.seen.delete(oldest); + } + this.seen.add(entry); + } + + /** + * One error per `(key, version)` whose envelope had no `contentHash`, so + * withheld skills are not mistaken for an environment with none. + */ + warnHashless(raw: RawSkillObject): void { + const identity = `${String(raw.key)}:${String(raw.version)}`; + if (this.seen.has(identity)) return; + this.remember(identity); + error( + `Skill '${String(raw.key)}' version ${String(raw.version)} arrived without a contentHash and will be ` + + `withheld. ${HASHLESS_ADVICE}`, + ); + } + + /** Forgets the summary, so a relapse after a recovery is reported afresh. */ + private forgetSummary(): void { + for (const entry of this.seen) { + if (entry.startsWith(SUMMARY_MARKER)) this.seen.delete(entry); + } + } + + /** + * One error per distinct committed state in which *nothing* held can verify. + * + * Fires at delivery time, so it shows even in a process that never reads a + * skill. Repeated only when the set of hashless objects changes, or after a + * recovery and relapse. + */ + warnIfNothingCanVerify(held: RawSkillObject[]): void { + const hashless = held.filter((raw) => typeof raw.contentHash !== 'string'); + if (hashless.length === 0) { + this.seen.clear(); + return; + } + if (hashless.length !== held.length) { + this.forgetSummary(); + return; + } + + const summary = + SUMMARY_MARKER + + hashless + .map((raw) => `${String(raw.key)}:${String(raw.version)}`) + .sort() + .join('\u0000'); + if (this.seen.has(summary)) return; + this.forgetSummary(); + this.remember(summary); + error( + `All ${held.length} skill object(s) in the delivered payload arrived without a contentHash. No skill ` + + `content will resolve from this store. ${HASHLESS_ADVICE}`, + ); + } +} + +// --------------------------------------------------------------------------- +// Deserialization +// --------------------------------------------------------------------------- + +/** A `delete-object` narrowed to the identity it revokes. */ +export type Tombstone = { readonly key: string; readonly objectVersion: number | null }; + +/** + * Whether one `put-object` / `delete-object` payload is a skill. + * + * Other kinds are ignored rather than rejected, so an unrecognised kind cannot + * cause a reconnect loop. + */ +export function isSkillEvent(data: unknown): boolean { + if (typeof data !== 'object' || data === null || Array.isArray(data)) return false; + return (data as { kind?: unknown }).kind === FDV2_OBJECT_KIND; +} + +/** + * A skill object's wire `key`, split into the skill's key and version. + * + * `version` is a number when the wire carried one, the offending text when it + * did not, and absent (`undefined`, with `hasVersion === false`) when the wire + * key had no delimiter at all. + */ +export type WireIdentity = { readonly key: string; readonly hasVersion: boolean; readonly version?: unknown }; + +const DIGITS_ONLY = /^[0-9]+$/; + +/** + * Splits `:` from one object's wire `key`. + * + * Malformed versions are kept so verification can report them under a + * recognisable key: + * + * - No delimiter: no version; verification reports `invalid_version`. + * - Non-digit version (`"pdf:latest"`, `"pdf:"`, `"a:1:2"`): the text is kept + * as the version. + * - Empty key (`":3"`): `null`; the object is dropped. + * + * Leading zeros are accepted (`"pdf:03"` is version 3). + */ +export function splitWireKey(wireKey: unknown): WireIdentity | null { + if (typeof wireKey !== 'string' || wireKey === '') return null; + const delimiter = wireKey.indexOf(FDV2_KEY_DELIMITER); + if (delimiter === -1) return { key: wireKey, hasVersion: false }; + const key = wireKey.slice(0, delimiter); + if (key === '') return null; + const versionText = wireKey.slice(delimiter + 1); + if (DIGITS_ONLY.test(versionText)) return { key, hasVersion: true, version: Number(versionText) }; + return { key, hasVersion: true, version: versionText }; +} + +/** + * Translates one FDv2 skill `put-object` into a raw `SkillStore` object. + * + * ``` + * wire `key` → stored `key` and `version` (split on `:`) + * wire `version` → dropped (the *payload* version) + * ``` + * + * The event's `version` is the payload's version, not the skill's; using it + * would serve content under a meaningless version number. + * + * Returns `null` only when the wire `key` has no skill key. Other defects are + * carried through so verification withholds them with a reason code. + */ +export function seamObjectFromPut(data: Record): RawSkillObject | null { + const identity = splitWireKey(data.key); + if (identity === null) { + warn( + `An FDv2 skill put-object carried no usable 'key' (${JSON.stringify(data.key)}) and could not be stored ` + + 'under any identity; it was dropped.', + ); + return null; + } + + const raw: RawSkillObject = { key: identity.key }; + + // Absent or malformed versions pass through for verification to report. + if (identity.hasVersion) raw.version = identity.version; + + const envelope = data.object; + if (typeof envelope === 'object' && envelope !== null && !Array.isArray(envelope)) { + for (const field of ENVELOPE_FIELDS) { + if (field in envelope) raw[field] = (envelope as Record)[field]; + } + } + return raw; +} + +/** The payload id one payload intent names, when it names a usable one. */ +function payloadIdOf(intent: unknown): string | null { + if (typeof intent !== 'object' || intent === null) return null; + const { id } = intent as { id?: unknown }; + return typeof id === 'string' && id !== '' ? id : null; +} + +/** The payload id inside a transfer's selector, when it carries one. */ +function payloadIdFromSelector(state: unknown): string | null { + if (typeof state !== 'string') return null; + const match = PAYLOAD_SELECTOR.exec(state); + return match ? match[1] : null; +} + +/** + * Narrows one FDv2 skill `delete-object` to the identity it revokes. + * + * A delete with no usable version (`objectVersion: null`) revokes every version + * of the key, including a version-less malformed entry. Erring this way avoids + * serving withdrawn content. + */ +export function tombstoneFromDelete(data: Record): Tombstone | null { + const identity = splitWireKey(data.key); + if (identity === null) { + warn(`An FDv2 skill delete-object carried no usable 'key' (${JSON.stringify(data.key)}); it was ignored.`); + return null; + } + return { + key: identity.key, + objectVersion: identity.hasVersion && isValidSkillVersion(identity.version) ? identity.version : null, + }; +} + +// --------------------------------------------------------------------------- +// The held object set +// --------------------------------------------------------------------------- + +/** + * Raw skill objects held in memory, keyed by `(key, version)`. + * + * Several versions of a key can coexist (the newest plus any pinned by a + * variation). An object with no usable version is held under its key alone so + * verification can withhold it with a reason. + */ +export class SkillObjectSet { + private versions = new Map>(); + private loose = new Map(); + + put(raw: RawSkillObject): void { + const key = raw.key as string; + const { version } = raw; + if (isValidSkillVersion(version)) { + const held = this.versions.get(key) ?? new Map(); + held.set(version, raw); + this.versions.set(key, held); + } else { + this.loose.set(key, raw); + } + } + + /** + * Removes what `tombstone` revokes; returns the raw objects that went away. + * A `null` version removes every version of the key. + */ + delete(tombstone: Tombstone): RawSkillObject[] { + const removed: RawSkillObject[] = []; + if (tombstone.objectVersion === null) { + for (const raw of this.versions.get(tombstone.key)?.values() ?? []) removed.push(raw); + this.versions.delete(tombstone.key); + const loose = this.loose.get(tombstone.key); + if (loose) { + removed.push(loose); + this.loose.delete(tombstone.key); + } + return removed; + } + + const held = this.versions.get(tombstone.key); + const gone = held?.get(tombstone.objectVersion); + if (gone) { + removed.push(gone); + held?.delete(tombstone.objectVersion); + } + if (held && held.size === 0) this.versions.delete(tombstone.key); + return removed; + } + + /** + * The object for `key` at `version`, or the newest held when `version` is null. + * + * When only a version-less entry exists it is served so verification can + * report it. A pin that misses while valid versions exist is a plain miss. + */ + get(key: string, version: number | null): RawSkillObject | null { + const held = this.versions.get(key); + if (!held || held.size === 0) return this.loose.get(key) ?? null; + if (version !== null) return held.get(version) ?? null; + return held.get(Math.max(...held.keys())) ?? null; + } + + /** + * One entry per skill key, at its newest version, keyed by the bare skill key + * (never `key:version`: `writeSkills('*')` prunes by these keys). Use `allRaw` + * for every held `(key, version)`. + */ + snapshot(): Record { + const out: Record = {}; + for (const [key, held] of this.versions) { + if (held.size === 0) continue; + const newest = Math.max(...held.keys()); + const raw = held.get(newest); + if (raw) out[key] = raw; + } + for (const [key, raw] of this.loose) { + if (!this.versions.has(key)) out[key] = raw; + } + return out; + } + + /** Every object held, one per `(key, version)`. */ + allRaw(): RawSkillObject[] { + const out: RawSkillObject[] = []; + for (const held of this.versions.values()) out.push(...held.values()); + out.push(...this.loose.values()); + return out; + } + + /** Adopts `other`'s contents wholesale — how a full transfer commits. */ + replaceWith(other: SkillObjectSet): void { + this.versions = other.versions; + this.loose = other.loose; + } + + copy(): SkillObjectSet { + const clone = new SkillObjectSet(); + clone.versions = new Map([...this.versions].map(([key, held]) => [key, new Map(held)])); + clone.loose = new Map(this.loose); + return clone; + } + + get size(): number { + let total = this.loose.size; + for (const held of this.versions.values()) total += held.size; + return total; + } +} + +// --------------------------------------------------------------------------- +// The protocol state machine — pure, no I/O +// --------------------------------------------------------------------------- + +/** What one event did. Aggregated by the caller; nothing here does I/O. */ +export type TransferOutcome = { + committed?: boolean; + changes?: RawSkillObject[]; + basis?: string | null; + fatal?: string | null; + disconnect?: string | null; + /** + * A `none` intent: the content held is current. Counts as a healthy exchange + * though it commits nothing, like a 304 to a poll. + */ + healthy?: boolean; + /** + * Set on a non-catastrophic `goodbye`. Still a disconnect; the caller decides + * whether it counts as a failure. + */ + expected?: boolean; +}; + +/** One object's comparable `(key, version)`; an unusable version is empty. */ +function identityOf(raw: RawSkillObject): string { + return `${String(raw.key)}\u0000${isValidSkillVersion(raw.version) ? raw.version : ''}`; +} + +/** + * Tombstones for every `(key, version)` `next` no longer holds. + * + * A full transfer revokes by omission; this recovers those revocations. A key + * whose version moved yields a put for the new version and a tombstone for the + * old one. + */ +function revocationsBetween(current: SkillObjectSet, next: SkillObjectSet): RawSkillObject[] { + const surviving = new Set(next.allRaw().map(identityOf)); + return current + .allRaw() + .filter((raw) => !surviving.has(identityOf(raw))) + .map((raw) => ({ key: raw.key, version: isValidSkillVersion(raw.version) ? raw.version : null })); +} + +/** + * How many keys in `revoked` left the payload entirely (not version moves). + * + * This is what `objectsRevoked` counts; `changes` carries every tombstone. + */ +function keysFullyRevoked(revoked: RawSkillObject[], next: SkillObjectSet): number { + const departed = new Set(); + for (const raw of revoked) { + const key = String(raw.key); + if (next.get(key, null) === null) departed.add(key); + } + return departed.size; +} + +type MutableDiagnostics = { -readonly [K in keyof StoreDiagnostics]: StoreDiagnostics[K] }; + +function freshDiagnostics(): MutableDiagnostics { + return { + payloadsTransferred: 0, + skillObjectsReceived: 0, + objectsIgnored: 0, + objectsRevoked: 0, + payloadsIgnored: 0, + hashlessObjects: 0, + connectionFailures: 0, + lastError: null, + }; +} + +/** + * Applies FDv2 events to an object set. Pure: no sockets, timers, or clock. + * + * - **Changes commit at `payload-transferred`**, never half-applied, so a full + * transfer never briefly empties the store and listeners fire only at commit. + * - **Only the first payload intent is read**, as the protocol requires, and it + * is taken to be the skill payload. The reader learns which payload carries + * skills and declines transfers of any other, which would otherwise empty the + * skill set. + */ +export class ProtocolReader { + readonly diagnostics = freshDiagnostics(); + private readonly hashless = new HashlessMemory(); + /** What this reader has reported hashless. Exposed for tests; not API. */ + readonly _warnedHashless: Set = this.hashless.seen; + private intent: string | null = null; + // Reset per `server-intent`: one warning per announcement. + private warnedUnknownIntent = false; + private pending: SkillObjectSet | null = null; + private changes: RawSkillObject[] = []; + // The payload the current intent describes, and the one skills arrive on; + // kept apart so a transfer of another payload can be declined. + private intentPayloadId: string | null = null; + private skillPayloadId: string | null = null; + private skillsInPayload = 0; + private warnedMultiplePayloads = false; + private warnedForeignPayload = false; + + constructor(private readonly committed: SkillObjectSet) {} + + /** Routes one event. Unknown event names are ignored, by contract. */ + handle(name: string, data: unknown): TransferOutcome { + switch (name) { + case EVENT_SERVER_INTENT: + return this.serverIntent(data); + case EVENT_PUT_OBJECT: + return this.putObject(data); + case EVENT_DELETE_OBJECT: + return this.deleteObject(data); + case EVENT_PAYLOAD_TRANSFERRED: + return this.payloadTransferred(data); + case EVENT_ERROR: + return this.error(data); + case EVENT_GOODBYE: + return this.goodbye(data); + case EVENT_HEARTBEAT: + return {}; + default: + return {}; + } + } + + private serverIntent(data: unknown): TransferOutcome { + const payloads = (data as { payloads?: unknown } | null)?.payloads; + if (!Array.isArray(payloads) || payloads.length === 0) { + return { disconnect: 'server-intent carried no payload description' }; + } + if (payloads.length > 1) this.warnMultiplePayloads(payloads); + // The first payload only, as the protocol requires. + const first = payloads[0] as { intentCode?: unknown } | null; + const intent = typeof first?.intentCode === 'string' ? first.intentCode : null; + this.intent = intent; + this.intentPayloadId = payloadIdOf(first); + this.changes = []; + this.skillsInPayload = 0; + this.warnedUnknownIntent = false; + if (intent === INTENT_TRANSFER_FULL) { + // Built beside the live set so an interrupted transfer keeps it. + this.pending = new SkillObjectSet(); + } else if (intent === INTENT_TRANSFER_CHANGES) { + this.pending = this.committed.copy(); + } else if (intent === INTENT_TRANSFER_NONE) { + // The basis is current, and later edits on this connection arrive as + // objects with no second intent: read on as a delta with nothing pending. + this.intent = INTENT_TRANSFER_CHANGES; + this.pending = null; + } else { + // Unknown intent codes are ignored; guessing could empty the store. + this.pending = null; + } + // Only `none` is a completed exchange; a transfer intent is not health + // until it commits. + return { healthy: intent === INTENT_TRANSFER_NONE }; + } + + private target(): SkillObjectSet | null { + if (this.pending === null && (this.intent === INTENT_TRANSFER_FULL || this.intent === INTENT_TRANSFER_CHANGES)) { + // An object with no preceding server-intent is treated as a delta. + this.pending = this.committed.copy(); + } + return this.pending; + } + + /** + * Drops a skill object that arrived under an intent this reader cannot apply + * (an unknown code). Counted under `objectsIgnored`, with one + * warning per intent. + */ + private ignoreUnderUnknownIntent(): TransferOutcome { + this.diagnostics.objectsIgnored += 1; + if (!this.warnedUnknownIntent) { + this.warnedUnknownIntent = true; + warn( + `Skill objects arrived under FDv2 intent code ${JSON.stringify(this.intent)}, which this SDK cannot ` + + 'apply; they are being ignored and counted under objectsIgnored. The skills held are unchanged. If ' + + 'this persists, update the SDK.', + ); + } + return {}; + } + + private putObject(data: unknown): TransferOutcome { + if (!isSkillEvent(data)) { + this.diagnostics.objectsIgnored += 1; + return {}; + } + if (this.pending === null && this.intent === null) this.intent = INTENT_TRANSFER_CHANGES; + const target = this.target(); + if (target === null) return this.ignoreUnderUnknownIntent(); + + const raw = seamObjectFromPut(data as Record); + if (raw === null) return {}; + target.put(raw); + this.changes.push(raw); + this.diagnostics.skillObjectsReceived += 1; + this.skillsInPayload += 1; + if (typeof raw.contentHash !== 'string') { + this.diagnostics.hashlessObjects += 1; + this.hashless.warnHashless(raw); + } + return {}; + } + + private deleteObject(data: unknown): TransferOutcome { + if (!isSkillEvent(data)) { + this.diagnostics.objectsIgnored += 1; + return {}; + } + if (this.pending === null && this.intent === null) this.intent = INTENT_TRANSFER_CHANGES; + const target = this.target(); + if (target === null) return this.ignoreUnderUnknownIntent(); + + const tombstone = tombstoneFromDelete(data as Record); + if (tombstone === null) return {}; + // Counted only if it removed something; listeners see it either way. + if (target.delete(tombstone).length > 0) this.diagnostics.objectsRevoked += 1; + // A revocation identifies the skill payload just as a put does. + this.skillsInPayload += 1; + this.changes.push({ key: tombstone.key, version: tombstone.objectVersion }); + return {}; + } + + private payloadTransferred(data: unknown): TransferOutcome { + const state = (data as { state?: unknown } | null)?.state; + const payloadId = this.intentPayloadId ?? payloadIdFromSelector(state); + // Checked even with no pending set (a `none` intent), so a foreign payload's + // selector never becomes the resume point. + const foreign = this.isForeignPayload(payloadId); + // Nothing is applied without a pending set — a `none` intent no object + // followed, or an intent code this SDK does not recognise — and a foreign + // payload's contents are declined below. + const applied = !foreign && this.pending !== null; + if (foreign) { + this.warnForeignPayload(payloadId); + this.diagnostics.payloadsIgnored += 1; + this.changes = []; + } else if (this.pending !== null) { + // A full transfer revokes by omission. Diff before the swap so those + // departures reach listeners as tombstones. + if (this.intent === INTENT_TRANSFER_FULL) { + const revoked = revocationsBetween(this.committed, this.pending); + this.changes.push(...revoked); + this.diagnostics.objectsRevoked += keysFullyRevoked(revoked, this.pending); + } + this.committed.replaceWith(this.pending); + this.hashless.warnIfNothingCanVerify(this.committed.allRaw()); + if (this.skillsInPayload > 0 && payloadId !== null) { + // The payload that carried a skill put or delete is the skill payload. + this.skillPayloadId = payloadId; + } + } + this.pending = null; + this.intent = null; + this.intentPayloadId = null; + this.skillsInPayload = 0; + const { changes } = this; + this.changes = []; + this.diagnostics.payloadsTransferred += 1; + if (!applied) { + // Nothing applied, so nothing to report — and in particular not a commit, + // because a commit publishes the first payload, and `isInitialized()` (what + // `writeSkills('*')` authorizes a prune on) must not go true over a store + // that received nothing. Not up to date either: only the `none` intent says + // that, on its own event. The selector is withheld too — it names a payload + // never applied. + return { changes: [], basis: null }; + } + return { + committed: true, + changes, + basis: typeof state !== 'string' || state === '' ? null : state, + }; + } + + /** Drops the in-flight payload and keeps what is committed. */ + private abandonInFlight(): void { + this.pending = null; + this.intent = null; + this.intentPayloadId = null; + this.skillsInPayload = 0; + this.changes = []; + } + + private error(data: unknown): TransferOutcome { + const reason = (data as { reason?: unknown } | null)?.reason; + this.abandonInFlight(); + return { disconnect: `server sent error: ${String(reason)}` }; + } + + private goodbye(data: unknown): TransferOutcome { + const parsed = (data ?? {}) as { reason?: unknown; silent?: unknown; catastrophe?: unknown }; + this.abandonInFlight(); + if (parsed.catastrophe === true) { + // Recoverable, as in the base SDKs, which do not read `catastrophe`: the + // streamer sets it on its own connection-error path, and treating it as + // fatal stopped delivery, revocations included, until `start()`. Never + // `expected`, so it is counted even after a completed exchange. Logged at + // error whatever `silent` says. + const reason = `server sent a catastrophic goodbye: ${String(parsed.reason)}`; + error(`FDv2 connection closing: ${reason}`); + return { disconnect: reason }; + } + // Debug only: a goodbye after a completed exchange is a routine recycle, and + // the reader cannot tell. The delivery loop warns for one that counts. + if (parsed.silent !== true) { + debug(`FDv2 connection closing: ${String(parsed.reason)}`); + } + // How the server recycles a long-lived stream. + return { disconnect: `server said goodbye: ${String(parsed.reason)}`, expected: true }; + } + + // -- payload identity ---------------------------------------------------- + + /** + * Whether a transfer completes a payload other than the skill payload. + * `false` unless both payload ids are known. + */ + private isForeignPayload(payloadId: string | null): boolean { + return this.skillPayloadId !== null && payloadId !== null && payloadId !== this.skillPayloadId; + } + + /** + * One warning per reader for an intent describing more than one payload: the + * first is then not guaranteed to be the skill payload. + */ + private warnMultiplePayloads(payloads: unknown[]): void { + if (this.warnedMultiplePayloads) return; + this.warnedMultiplePayloads = true; + warn( + `An FDv2 server-intent described ${payloads.length} payloads (${payloads + .map((p) => String(payloadIdOf(p))) + .join(', ')}). Only the first is read, as the protocol requires, and it is taken to be the payload skills ` + + 'arrive on. If skills stop resolving from this point, that is the assumption that broke; contact ' + + 'LaunchDarkly support.', + ); + } + + /** One warning per reader for a transfer this layer declined to apply. */ + private warnForeignPayload(payloadId: string | null): void { + if (this.warnedForeignPayload) return; + this.warnedForeignPayload = true; + warn( + `An FDv2 transfer of payload ${String(payloadId)} was not applied to the skills held, which arrive on ` + + `payload ${String(this.skillPayloadId)}. Applying it would have replaced them with whatever that payload ` + + 'carried — nothing, in the case of a flag payload. The skills held are unchanged.', + ); + } +} + +// --------------------------------------------------------------------------- +// HTTP +// --------------------------------------------------------------------------- + +/** A failure retrying cannot fix: bad credential, forbidden, wrong URI. */ +export class FatalTransportError extends Error {} + +/** A failure worth retrying. Carries a server-requested delay when given one. */ +export class RecoverableTransportError extends Error { + constructor( + message: string, + readonly retryAfterMs: number | null = null, + /** + * A `goodbye` after a completed exchange: retried, but not logged as a + * failure or counted in `connectionFailures`. + */ + readonly expected = false, + ) { + super(message); + } +} + +/** + * An HTTP 400. If the request carried client state (`basis` or an + * `If-None-Match` etag), that state is dropped and a full transfer requested + * once; a 400 for a request with no state is fatal. + */ +export class StaleRequestStateError extends RecoverableTransportError {} + +const REQUEST_ADVICE = + 'The request this adapter sent was not understood. It carries only the SDK key, a ' + + "'kinds' parameter declaring the skill payload, and, after the first payload, a 'basis' selector, so check " + + 'the base URI and that the endpoint speaks FDv2.'; + +const FORBIDDEN_ADVICE = + 'The FDv2 protocol is opt-in per LaunchDarkly account and is served as HTTP 403 while it is off. Skill ' + + 'delivery needs it enabled; contact LaunchDarkly support to enable it for your account.'; + +/** + * `Retry-After` in milliseconds, or `null` when absent or unusable (blank, the + * HTTP-date form, or a non-finite number), in which case normal backoff applies. + */ +export function retryAfterMs(headers: Headers | null | undefined): number | null { + const raw = headers?.get('Retry-After'); + if (raw === null || raw === undefined) return null; + const value = raw.trim(); + // `Number('')` is 0, so a blank header would otherwise read as no delay. + if (value === '') return null; + const seconds = Number(value); + if (!Number.isFinite(seconds)) return null; + return Math.max(0, seconds * 1000); +} + +/** + * The fatal error for a 3xx. Requests use `redirect: 'manual'` because following + * a redirect would forward the `Authorization` header (the SDK key) to whatever + * host `Location` names. The FDv2 endpoints never redirect. + */ +function redirectRefused(status: string): FatalTransportError { + return new FatalTransportError( + `LaunchDarkly returned ${status}, a redirect. Redirects are not followed, so the SDK key is never forwarded to a ` + + 'host other than the base URI. The SDK-facing FDv2 endpoints do not redirect; check the base URI, and any proxy ' + + 'in between, for the address being redirected to.', + ); +} + +/** + * The error for a redirect response (including an `opaqueredirect`), or `null` + * when the response is not a redirect. + */ +function refusedRedirect(response: Response): FatalTransportError | null { + if (response.type === 'opaqueredirect') return redirectRefused('a 3xx status'); + if (response.status >= 300 && response.status < 400 && response.status !== 304) { + return classifyStatus(response.status, response.headers); + } + return null; +} + +/** Turns an HTTP error status into the right error type. */ +export function classifyStatus(status: number, headers?: Headers | null): Error { + if (status === 401) { + return new FatalTransportError( + 'LaunchDarkly rejected the SDK key (HTTP 401). Skill delivery cannot start. Check that the key is the ' + + "environment's server-side SDK key.", + ); + } + if (status === 403) return new FatalTransportError(`LaunchDarkly returned HTTP 403. ${FORBIDDEN_ADVICE}`); + if (status >= 300 && status < 400 && status !== 304) return redirectRefused(`HTTP ${status}`); + if (status === 404) { + // Typically a mistyped base URI; retrying will not help. + return new FatalTransportError( + 'LaunchDarkly returned HTTP 404 for the FDv2 endpoint. Check the base URI, and that this instance serves ' + + '/sdk/poll and /sdk/stream.', + ); + } + // The selector sent may be stale: recoverable once (see `StaleRequestStateError`). + if (status === 400) return new StaleRequestStateError(`LaunchDarkly returned HTTP 400. ${REQUEST_ADVICE}`); + + // No payload matching the declared kinds is available on this connection, + // most often because of a view-scoped SDK key. Fatal. + if (status === 422) { + return new FatalTransportError( + 'LaunchDarkly will not deliver Agent Skills on this connection (HTTP 422). The usual cause is a view-scoped ' + + 'SDK key. Check your SDK key or contact LaunchDarkly support.', + ); + } + if ([405, 406, 414, 501].includes(status)) { + return new FatalTransportError( + `LaunchDarkly returned HTTP ${status}, which retrying will not fix. ${REQUEST_ADVICE}`, + ); + } + return new RecoverableTransportError(`LaunchDarkly returned HTTP ${status}`, retryAfterMs(headers)); +} + +export type PollResult = { + readonly notModified: boolean; + readonly events: Array<[string, unknown]>; + readonly etag: string | null; +}; + +/** + * Reads a whole poll body in chunks, abandoning it as soon as it exceeds `limit` + * UTF-16 code units (fatal; see {@link MAX_RESPONSE_CHARS}). + * + * Does not touch the {@link ReadDeadline}, so in `'poll'` mode the timeout + * bounds the whole request. + */ +async function readBoundedText(body: ReadableStream, limit: number): Promise { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let text = ''; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + if (text.length > limit) { + throw new FatalTransportError( + `polling response exceeded the ${limit} character transport bound ` + + `(at least ${text.length} received); nothing from it was applied. ${OVERSIZED_ADVICE}`, + ); + } + } + // Flush a truncated trailing sequence as U+FFFD so `JSON.parse` rejects it. + return text + decoder.decode(); + } finally { + // Close the connection so an abandoned body leaves no socket open. + reader.cancel().catch(() => {}); + try { + reader.releaseLock(); + } catch { + // Already released, or the stream errored. Nothing to recover. + } + } +} + +/** + * Unwraps `{"events": [...]}`. Poll and stream events are identical, so both + * modes share one protocol reader. + */ +export function decodePollBody(body: string): Array<[string, unknown]> { + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch (cause) { + throw new RecoverableTransportError( + `polling response was not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + const wrapped = parsed as { events?: unknown } | null; + if (typeof wrapped !== 'object' || wrapped === null || !Array.isArray(wrapped.events)) { + throw new RecoverableTransportError("polling response had no 'events' array"); + } + const events: Array<[string, unknown]> = []; + for (const entry of wrapped.events) { + if (typeof entry !== 'object' || entry === null) continue; + const { event, data } = entry as { event?: unknown; data?: unknown }; + if (typeof event === 'string') events.push([event, data]); + } + return events; +} + +/** + * A signal that aborts when `parent` does, or when `ms` pass without `touch()`. + * + * The one network timeout: untouched in `'poll'` mode (bounds the whole + * request), touched per read in `'stream'` mode (bounds the gap between reads). + * `expired` distinguishes a timeout from the store closing. + */ +export type ReadDeadline = { + readonly signal: AbortSignal; + readonly parent: AbortSignal; + readonly ms: number; + readonly expired: boolean; + /** Restarts the clock. Call after each successful read. */ + touch(): void; + /** Stops the clock and detaches from `parent`. Idempotent. */ + clear(): void; +}; + +export function readDeadline(parent: AbortSignal, ms: number): ReadDeadline { + const controller = new AbortController(); + let timer: ReturnType | null = null; + let expired = false; + const stopTimer = (): void => { + if (timer !== null) clearTimeout(timer); + timer = null; + }; + const onParentAbort = (): void => { + stopTimer(); + controller.abort(parent.reason); + }; + const arm = (): void => { + stopTimer(); + if (controller.signal.aborted) return; + timer = setTimeout(() => { + expired = true; + controller.abort(new Error(`no response within ${ms}ms`)); + }, ms); + // Unreffed so the timer never keeps the process alive. + (timer as unknown as { unref?: () => void }).unref?.(); + }; + if (parent.aborted) controller.abort(parent.reason); + else parent.addEventListener('abort', onParentAbort, { once: true }); + arm(); + return { + signal: controller.signal, + parent, + ms, + get expired() { + return expired; + }, + touch: arm, + clear: () => { + stopTimer(); + parent.removeEventListener('abort', onParentAbort); + }, + }; +} + +/** + * Wraps a failed request or read as `RecoverableTransportError`, unless the store + * is closing (the abort passes through). The delivery loop treats other errors + * as bugs and stops. + */ +function readFailure(cause: unknown, what: string, deadline: ReadDeadline | undefined): unknown { + if (deadline?.parent.aborted) return cause; + if (deadline?.expired) return new RecoverableTransportError(`${what} timed out: no bytes in ${deadline.ms}ms`); + return new RecoverableTransportError(`${what} failed: ${cause instanceof Error ? cause.message : String(cause)}`); +} + +/** + * The most the transport holds in memory from one poll body or one streamed + * event, in UTF-16 code units. + * + * A memory backstop set far above any real payload, separate from the per-skill + * content limit verification enforces. Crossing it is fatal, like a 422: nothing + * is applied and the store keeps its current content, but delivery stops. The + * payload's size belongs to the environment, not the connection, so a retry + * would download up to this much again on every backoff step and be refused the + * same way. + */ +export const MAX_RESPONSE_CHARS = 64 * 1024 * 1024; + +const OVERSIZED_ADVICE = + 'The payload is larger than this SDK will hold in memory, and a retry would be refused the same way. Contact ' + + 'LaunchDarkly support.'; + +/** + * Decodes an SSE byte stream into `[event name, data]` pairs. + * + * Minimal: `event:`/`data:` fields, multi-line `data` joined with newlines, + * blank line dispatches, `:` comments skipped. An event over + * {@link MAX_RESPONSE_CHARS} throws a fatal error. A known event whose data is + * not JSON throws a recoverable one, ending the connection; any other event is + * yielded with `null` data, unparsed. Only read failures are wrapped; errors + * thrown by the consumer pass through unchanged. + */ +export async function* iterSse( + body: ReadableStream, + deadline?: ReadDeadline, +): AsyncGenerator<[string, unknown], void, undefined> { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let name: string | null = null; + let dataLines: string[] = []; + let dataChars = 0; + + // Checked separately: the tail is only added to the event's data once every + // complete line in the buffer has been consumed. + const dataOverBound = (): boolean => dataChars > MAX_RESPONSE_CHARS; + const tailOverBound = (): boolean => buffer.length + dataChars > MAX_RESPONSE_CHARS; + + // Clears the buffered fields at every block end. A block with no `event:` + // field is dropped. A known event's data that is not JSON ends the + // connection rather than being skipped: the `payload-transferred` after it + // would otherwise commit the transfer without it and advance the basis past + // it, so a lost `delete-object` would never be sent again. The reconnect + // resumes from the last committed basis, as the base SDK's + // `PayloadStreamReader` does. Empty data is not JSON either: read as null, a + // `delete-object` would be ignored and a `payload-transferred` would commit + // with no selector. Other events are not parsed, so their data cannot end + // the connection. + const dispatch = (): [string, unknown] | null => { + const eventName = name; + const payload = dataLines.join('\n'); + name = null; + dataLines = []; + dataChars = 0; + if (eventName === null) return null; + if (!EVENTS_WITH_DATA.has(eventName)) return [eventName, null]; + try { + return [eventName, JSON.parse(payload)]; + } catch { + throw new RecoverableTransportError( + `the FDv2 stream sent a '${eventName}' event whose data was not JSON; the payload in flight was abandoned`, + ); + } + }; + + try { + for (;;) { + let chunk: ReadableStreamReadResult; + try { + chunk = await reader.read(); + } catch (cause) { + throw readFailure(cause, 'reading the FDv2 stream', deadline); + } + deadline?.touch(); + const { done, value } = chunk; + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + const line = buffer.slice(0, newline).replace(/\r$/, ''); + buffer = buffer.slice(newline + 1); + if (line === '') { + const dispatched = dispatch(); + if (dispatched !== null) yield dispatched; + } else if (!line.startsWith(':')) { + const colon = line.indexOf(':'); + const field = colon === -1 ? line : line.slice(0, colon); + let value2 = colon === -1 ? '' : line.slice(colon + 1); + if (value2.startsWith(' ')) value2 = value2.slice(1); + if (field === 'event') name = value2; + else if (field === 'data') { + dataLines.push(value2); + dataChars += value2.length + 1; + if (dataOverBound()) { + throw new FatalTransportError( + `the FDv2 stream sent more than ${MAX_RESPONSE_CHARS} characters of data for one event; ` + + `nothing from it was applied. ${OVERSIZED_ADVICE}`, + ); + } + } + } + newline = buffer.indexOf('\n'); + } + if (tailOverBound()) { + throw new FatalTransportError( + `the FDv2 stream sent more than ${MAX_RESPONSE_CHARS} characters without completing an event; ` + + `nothing from it was applied. ${OVERSIZED_ADVICE}`, + ); + } + } + } finally { + deadline?.clear(); + // Close the connection so a consumer that stops early leaves no socket open. + reader.cancel().catch(() => {}); + try { + reader.releaseLock(); + } catch { + // Already released, or the stream errored. Nothing to recover. + } + } +} + +/** What the store needs from a transport. Swapped wholesale in tests. */ +export type Requester = { + poll(basis: string | null, etag: string | null, signal: AbortSignal): Promise; + stream(basis: string | null, signal: AbortSignal): Promise>; +}; + +/** + * The only place this module opens a connection. + * + * `readTimeoutSeconds` bounds every request through a {@link ReadDeadline} + * (connect, headers, body); there is no separate connect timeout. + */ +export class FetchRequester implements Requester { + /** Origin `GET /sdk/poll` is sent to. */ + readonly baseUri: string; + /** Origin `GET /sdk/stream` is sent to. Defaults to `baseUri`. */ + readonly streamUri: string; + + constructor( + private readonly sdkKey: string, + baseUri: string, + readonly readTimeoutSeconds: number, + streamUri: string = baseUri, + ) { + this.baseUri = baseUri.replace(/\/+$/, ''); + this.streamUri = streamUri.replace(/\/+$/, ''); + } + + /** + * The request URL: `?kinds=` on every request (see {@link FDV2_PAYLOAD_KIND}), + * plus `basis` once a payload has committed. + * + * No `mv` parameter: it selects the flag data model and does not apply to + * skills. + */ + private url(origin: string, path: string, basis: string | null): string { + const params = new URLSearchParams({ kinds: FDV2_PAYLOAD_KIND }); + if (basis) params.set('basis', basis); + return `${origin}${path}?${params.toString()}`; + } + + /** One `GET /sdk/poll`. A 304 is a first-class outcome, not an error. */ + async poll(basis: string | null, etag: string | null, signal: AbortSignal): Promise { + const headers: Record = { Authorization: this.sdkKey, Accept: 'application/json' }; + if (etag) headers['If-None-Match'] = etag; + + // Never touched, so it bounds the whole request. + const deadline = readDeadline(signal, secondsToMs(this.readTimeoutSeconds, 1)); + try { + const response = await fetch(this.url(this.baseUri, POLL_PATH, basis), { + headers, + signal: deadline.signal, + redirect: 'manual', + }); + // Handled before the redirect check: a 304 means "unchanged". + if (response.status === 304) return { notModified: true, events: [], etag }; + const redirect = refusedRedirect(response); + if (redirect) throw redirect; + if (!response.ok) throw classifyStatus(response.status, response.headers); + // An empty body fails in `decodePollBody` as a recoverable error. + const body = response.body === null ? '' : await readBoundedText(response.body, MAX_RESPONSE_CHARS); + return { + notModified: false, + events: decodePollBody(body), + etag: response.headers.get('ETag') ?? etag, + }; + } catch (cause) { + if (cause instanceof FatalTransportError || cause instanceof RecoverableTransportError) throw cause; + throw readFailure(cause, 'polling request', deadline); + } finally { + deadline.clear(); + } + } + + /** Opens `GET /sdk/stream` and yields `[event name, data]` pairs. */ + async stream(basis: string | null, signal: AbortSignal): Promise> { + const headers = { + Authorization: this.sdkKey, + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + }; + + // Bounds the connect, then (touched by `iterSse`) the gap between reads. + const deadline = readDeadline(signal, secondsToMs(this.readTimeoutSeconds, 1)); + let response: Response; + try { + response = await fetch(this.url(this.streamUri, STREAM_PATH, basis), { + headers, + signal: deadline.signal, + redirect: 'manual', + }); + } catch (cause) { + deadline.clear(); + throw readFailure(cause, 'streaming request', deadline); + } + + const redirect = refusedRedirect(response); + if (redirect) { + deadline.clear(); + throw redirect; + } + if (!response.ok) { + deadline.clear(); + throw classifyStatus(response.status, response.headers); + } + if (response.body === null) { + deadline.clear(); + throw new RecoverableTransportError('the FDv2 stream carried no body'); + } + deadline.touch(); + return iterSse(response.body, deadline); + } +} + +// --------------------------------------------------------------------------- +// Backoff +// --------------------------------------------------------------------------- + +/** + * Exponential backoff with jitter, capped at `maximumMs`. + * + * Jitter is subtracted, never added, so `maximumMs` is a true ceiling. + */ +export function backoffDelayMs(attempt: number, baseMs: number, maximumMs: number, jitter = 0.5): number { + // Retries are unbounded, so the attempt number is too. Clamped so the power + // stays finite; 2^30 of any base passes any cap. The constructor refuses a + // non-positive base, so a zero delay never comes out of here. + const exponent = Math.min(Math.max(0, attempt - 1), 30); + const ceiling = Math.min(maximumMs, baseMs * 2 ** exponent); + return ceiling * (1 - jitter * Math.random()); +} + +/** + * How long a stream must stay open for the reconnect after it to start again at + * `initialBackoffSeconds`, as in the base server-side SDKs. Internal, not an + * option. + */ +export const BACKOFF_RESET_INTERVAL_MS = 60_000; + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) { + resolve(); + return; + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + // Unreffed so a pending backoff never keeps the process alive. + (timer as unknown as { unref?: () => void }).unref?.(); + const onAbort = (): void => { + clearTimeout(timer); + resolve(); + }; + signal.addEventListener('abort', onAbort, { once: true }); + }); +} + +// --------------------------------------------------------------------------- +// The store +// --------------------------------------------------------------------------- + +export type FDv2SkillStoreOptions = { + /** + * `'stream'` (default, recommended: revocations arrive in seconds) or + * `'poll'` (revocations arrive within one `pollIntervalSeconds`). + */ + readonly mode?: FDv2Mode; + /** + * Origin for `GET /sdk/poll` (default {@link SKILLS_DEFAULT_BASE_URI}). Given without + * `streamUri`, it is used for streaming too (relays and private instances). + * + * Must be `https://`; `http://` is accepted only for `localhost`, `127.0.0.1` + * or `::1`. The constructor throws otherwise. + */ + readonly baseUri?: string; + /** + * Origin for `GET /sdk/stream` (default {@link SKILLS_DEFAULT_STREAM_URI}, or + * `baseUri` when that is given). Same `https://` rule as `baseUri`. + */ + readonly streamUri?: string; + /** Seconds between polls; positive and finite. Default `30`. */ + readonly pollIntervalSeconds?: number; + /** + * The only network timeout, in seconds; positive and finite. In `'poll'` mode + * it bounds the whole request ({@link DEFAULT_POLL_TIMEOUT_SECONDS}); in + * `'stream'` mode, each wait for more bytes + * ({@link DEFAULT_STREAM_READ_TIMEOUT_SECONDS}). + */ + readonly readTimeoutSeconds?: number; + /** + * The first retry delay, in seconds; positive, finite, and no greater than + * `maxBackoffSeconds`. Default `1`. + */ + readonly initialBackoffSeconds?: number; + /** + * Caps every retry delay, in seconds, including `Retry-After`; positive and + * finite. Default `30`. Recoverable failures are retried for the life of the + * store; only a fatal status stops delivery. + */ + readonly maxBackoffSeconds?: number; + /** Replaces the built-in `fetch` transport. Intended for testing. */ + readonly requester?: Requester; +}; + +/** + * A `SkillStore` fed by LaunchDarkly's SDK-facing FDv2 delivery channel. + * + * Constructed with the environment's server-side SDK key, started explicitly, + * and passed to `setSkillStore`: + * + * ```ts + * const store = new FDv2SkillStore(process.env.LD_SDK_KEY!).start(); + * await store.waitForSkills({ timeoutSeconds: 10 }); + * setSkillStore(store); + * + * const skill = await getSkill('pdf-extraction'); + * // ... + * await store.close(); + * ``` + * + * - **Server-side only.** A mobile key or client-side environment ID throws. + * - **The SDK key stays where you point it.** URIs must be `https://` (`http://` + * only to loopback) and redirects are never followed. + * - **Delivery runs in the background.** `getObject` reads only what has + * arrived. Use `waitForSkills` or `isInitialized` before relying on content. + * - **Last known good survives an outage.** A transport failure never empties + * the store or makes `getObject` throw; `diagnostics` and `failed` report it. + * - **`close` is final.** A closed store still serves what it received, but + * `start` throws; construct a new store instead. + * - **Content is verified by the accessors, not here.** An object with no + * `contentHash` is held and then withheld; see + * {@link StoreDiagnostics.hashlessObjects}. + */ +export class FDv2SkillStore implements SkillStore { + private readonly objects = new SkillObjectSet(); + private readonly reader = new ProtocolReader(this.objects); + private readonly listeners = new Map unknown>>(); + private readonly requester: Requester; + private readonly mode: FDv2Mode; + private readonly pollIntervalSeconds: number; + private readonly initialBackoffSeconds: number; + private readonly maxBackoffSeconds: number; + + private basis: string | null = null; + private etag: string | null = null; + /** The basis `etag` was issued for; the etag is only sent with it. */ + private etagBasis: string | null = null; + private controller: AbortController | null = null; + private loop: Promise | null = null; + private failedReason: string | null = null; + // Set by `close`. Separate from `failedReason`: closing is not a failure. + private closed = false; + private firstPayload = false; + private readonly firstPayloadWaiters: Array<() => void> = []; + // Recoverable failures in a row; reset by a completed exchange, not by a + // connection ending (a stream only ends by being dropped or a goodbye). + private failures = 0; + // Whether the current attempt completed an exchange, which separates a + // recycled healthy stream from a failed one. + private reachedServer = false; + // The backoff step, kept apart from `failures`: a completed exchange does not + // reset it, or a server that answers and then drops would be reconnected at + // `initialBackoffSeconds` for as long as it stayed degraded. It resets after a + // stream outlives `_backoffResetIntervalMs`, or a completed poll. + private backoffAttempt = 0; + // When the current stream connection opened (`performance.now()`), or `null` + // before it has. + private connectedAt: number | null = null; + /** {@link BACKOFF_RESET_INTERVAL_MS}. Exposed for tests; not API. */ + _backoffResetIntervalMs = BACKOFF_RESET_INTERVAL_MS; + + constructor(sdkKey: string, options: FDv2SkillStoreOptions = {}) { + const key = requireServerSideCredential(sdkKey); + this.mode = options.mode ?? 'stream'; + if (this.mode !== 'stream' && this.mode !== 'poll') { + throw new Error(`mode must be 'stream' or 'poll', got ${JSON.stringify(options.mode)}`); + } + // The retired millisecond names are not read. TypeScript rejects them in an + // object literal; a JavaScript caller, or a spread, would otherwise poll on + // the defaults with no signal. One warning names every one that is present. + const retiredMs = ( + [ + ['pollIntervalMs', 'pollIntervalSeconds'], + ['readTimeoutMs', 'readTimeoutSeconds'], + ['initialBackoffMs', 'initialBackoffSeconds'], + ['maxBackoffMs', 'maxBackoffSeconds'], + ] as const + ).filter(([key]) => Object.hasOwn(options, key)); + if (retiredMs.length > 0) { + warn(retiredMs.map(([was, next]) => `${was} is no longer read; use ${next} (seconds)`).join('. ')); + } + // Every time option is in seconds, and stays in seconds until `secondsToMs` + // at the timer or deadline it sets. + this.pollIntervalSeconds = options.pollIntervalSeconds ?? 30; + // `NaN` passes a `<= 0` check, and `setTimeout(fn, NaN)` fires at once. + if ( + typeof this.pollIntervalSeconds !== 'number' || + !Number.isFinite(this.pollIntervalSeconds) || + this.pollIntervalSeconds <= 0 + ) { + throw new Error( + `pollIntervalSeconds must be a positive, finite number, got ${String(options.pollIntervalSeconds)}`, + ); + } + const readTimeoutSeconds = + options.readTimeoutSeconds ?? + (this.mode === 'stream' ? DEFAULT_STREAM_READ_TIMEOUT_SECONDS : DEFAULT_POLL_TIMEOUT_SECONDS); + if (typeof readTimeoutSeconds !== 'number' || !Number.isFinite(readTimeoutSeconds) || readTimeoutSeconds <= 0) { + throw new Error( + `readTimeoutSeconds must be a positive, finite number, got ${String(options.readTimeoutSeconds)}`, + ); + } + this.initialBackoffSeconds = options.initialBackoffSeconds ?? 1; + this.maxBackoffSeconds = options.maxBackoffSeconds ?? 30; + // With no failure bound these two are the only limit on the retry loop: a + // zero, negative or `NaN` delay is no wait at all, against a failing server. + for (const [name, value] of [ + ['initialBackoffSeconds', this.initialBackoffSeconds], + ['maxBackoffSeconds', this.maxBackoffSeconds], + ] as const) { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive, finite number, got ${String(value)}`); + } + } + if (this.initialBackoffSeconds > this.maxBackoffSeconds) { + throw new Error( + `initialBackoffSeconds (${this.initialBackoffSeconds}) must not exceed maxBackoffSeconds ` + + `(${this.maxBackoffSeconds})`, + ); + } + const baseUri = requireHttpsUri(options.baseUri ?? SKILLS_DEFAULT_BASE_URI); + // A lone `baseUri` serves both endpoints. + const streamUri = requireHttpsUri( + options.streamUri ?? (options.baseUri === undefined ? SKILLS_DEFAULT_STREAM_URI : baseUri), + 'streamUri', + ); + this.requester = options.requester ?? new FetchRequester(key, baseUri, readTimeoutSeconds, streamUri); + } + + // -- lifecycle --------------------------------------------------------- + + /** + * Starts delivery. Idempotent; returns `this` so it chains. + * + * Does not await: use `waitForSkills` when boot ordering matters. + * + * Throws if the store has been closed. A store whose delivery stopped on its + * own (`failed` is set) can be started again, with its failure count and + * backoff started over. + */ + start(): this { + if (this.closed) { + throw new Error( + 'this FDv2SkillStore has been closed and cannot be restarted; construct a new FDv2SkillStore instead. ' + + 'A closed store still answers from the content it received, so retrieval needs no restart.', + ); + } + if (this.loop !== null && this.failedReason === null) return this; + // A loop that gave up has already returned, so a new one cannot overlap it. + this.failedReason = null; + this.failures = 0; + this.reader.diagnostics.connectionFailures = 0; + this.backoffAttempt = 0; + this.controller = new AbortController(); + this.loop = this.run(this.controller.signal); + return this; + } + + /** + * Stops delivery and resolves once the delivery loop has exited. Idempotent. + * + * **Final:** `start` throws afterwards; construct a new store to resume. + * Held content is kept, so a closed store still serves what it received. + * `failed` stays `null`, and pending `waitForSkills` calls resolve at once. + * `shutdown()` detaches the store from the accessors. + */ + async close(): Promise { + this.closed = true; + this.controller?.abort(); + const loop = this.loop; + this.loop = null; + this.releaseWaiters(); + if (loop) await loop; + } + + /** + * Resolves once the first payload arrives, or after `timeoutSeconds` + * (default `10`). + * + * Resolves `true` once a payload has committed or a 304 confirmed the one held + * is current. That does not mean any skill verified, or that the environment + * has skills; see `diagnostics`. + * + * Resolves `false` on timeout, or immediately if delivery has ended (`close`, + * or a failure that will not be retried; `failed` tells them apart). A store + * that gave up waits again once `start()` runs delivery again; only `close` is + * final. + * + * Takes an options object rather than a bare number, as the base SDK's + * `waitForInitialization({ timeoutSeconds })` does. Rejects with a + * `TypeError` for anything else, a number included: the timeout used to be a + * positional millisecond count, and reading `waitForSkills(10_000)` as seconds + * would wait almost three hours. Rejects for a negative or non-finite + * `timeoutSeconds`. + */ + waitForSkills(options: { readonly timeoutSeconds?: number } = {}): Promise { + if (typeof options !== 'object' || options === null || Array.isArray(options)) { + // `String([])` is empty and `String('10')` hides the quotes, so the value + // is shown with its type (or JSON, for a string, null, or array). + const got = + typeof options === 'object' || typeof options === 'string' + ? JSON.stringify(options) + : `${typeof options} ${String(options)}`; + return Promise.reject( + new TypeError( + `waitForSkills takes an options object, { timeoutSeconds }, got ${got}. ` + + 'The timeout is in seconds: waitForSkills(10_000) is now waitForSkills({ timeoutSeconds: 10 }).', + ), + ); + } + const { timeoutSeconds = 10 } = options; + if (typeof timeoutSeconds !== 'number' || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 0) { + // `setTimeout(fn, NaN)` fires at once and would read as a timeout. + return Promise.reject( + new Error(`waitForSkills timeoutSeconds must be a non-negative, finite number, got ${String(timeoutSeconds)}`), + ); + } + if (this.firstPayload) return Promise.resolve(true); + // No payload can arrive any more. + if (this.closed || this.failedReason !== null) return Promise.resolve(false); + return new Promise((resolve) => { + const waiter = (): void => { + clearTimeout(timer); + resolve(this.firstPayload); + }; + const timer = setTimeout(() => { + // Remove the expired waiter so it is not retained. + this.dropWaiter(waiter); + resolve(this.firstPayload); + }, secondsToMs(timeoutSeconds)); + // Deliberately not unreffed, unlike every other timer here. During an + // outage nothing else holds the process up, so an unreffed wait let `node` + // exit mid-`await`. Bounded by `timeoutSeconds`, and cleared on release. + this.firstPayloadWaiters.push(waiter); + }); + } + + private dropWaiter(waiter: () => void): void { + const index = this.firstPayloadWaiters.indexOf(waiter); + if (index !== -1) this.firstPayloadWaiters.splice(index, 1); + } + + private releaseWaiters(): void { + while (this.firstPayloadWaiters.length > 0) this.firstPayloadWaiters.pop()?.(); + } + + private markFirstPayload(): void { + this.firstPayload = true; + this.releaseWaiters(); + } + + /** + * Whether a payload has arrived: `waitForSkills` without the wait. + * + * Optional `SkillStore` method. `writeSkills('*')` checks it so a reconcile + * before first delivery reports "unavailable" instead of pruning every skill. + * Stays `true` after `close`. + */ + isInitialized(): boolean { + return this.firstPayload; + } + + /** + * Why delivery stopped, or `null` while it is running. Cleared when `start` + * runs delivery again. + */ + get failed(): string | null { + return this.failedReason; + } + + /** A snapshot of what the transport has seen. See {@link StoreDiagnostics}. */ + get diagnostics(): StoreDiagnostics { + return { ...this.reader.diagnostics }; + } + + // -- the SkillStore interface ----------------------------------------- + + getObject(kind: string, key: string, version?: number | null): RawSkillObject | null { + if (kind !== SKILL_OBJECT_KIND) return null; + return this.objects.get(key, version ?? null); + } + + allObjects(kind: string): Record { + if (kind !== SKILL_OBJECT_KIND) return {}; + return this.objects.snapshot(); + } + + /** + * Registers `fn` to be called once per changed object, when a payload commits + * (never mid-transfer). + * + * - A put passes the raw skill object. + * - A revocation passes a `{ key, version }` tombstone with no `content`; + * check for `content` before reading it. + * + * `fn` runs inline on the delivery task: keep it cheap and non-blocking. + * Errors it throws (or rejections, if async) are logged and swallowed. + * + * Throws for any `kind` other than `'skill'`, since such a listener would + * never fire. + */ + addListener(kind: string, fn: (raw: RawSkillObject) => unknown): void { + if (kind !== SKILL_OBJECT_KIND) { + throw new Error( + `FDv2SkillStore notifies only '${SKILL_OBJECT_KIND}' changes, so a listener on ${JSON.stringify(kind)} ` + + `would never fire. Register it on '${SKILL_OBJECT_KIND}'.`, + ); + } + const existing = this.listeners.get(kind); + if (existing) existing.push(fn); + else this.listeners.set(kind, [fn]); + } + + /** + * Unregisters one occurrence of `fn` from `kind`; a no-op if it is not + * registered (any `kind` is accepted). Safe inside a listener (takes effect + * from the next commit). + */ + removeListener(kind: string, fn: (raw: RawSkillObject) => unknown): void { + const listeners = this.listeners.get(kind); + if (!listeners) return; + const index = listeners.indexOf(fn); + if (index !== -1) listeners.splice(index, 1); + } + + private notify(changes: RawSkillObject[]): void { + // Copied so a removal mid-commit does not disturb iteration. + const listeners = [...(this.listeners.get(SKILL_OBJECT_KIND) ?? [])]; + const report = (cause: unknown): void => { + error( + `A skill store change listener threw; delivery continues: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + ); + }; + for (const raw of changes) { + for (const listener of listeners) { + try { + const result = listener(raw); + // Catch async rejections too, so they never go unhandled. + if (result instanceof Promise) result.catch(report); + } catch (cause) { + report(cause); + } + } + } + } + + // -- the delivery loop ------------------------------------------------- + + private async run(signal: AbortSignal): Promise { + while (!signal.aborted) { + try { + this.reachedServer = false; + this.connectedAt = null; + if (this.mode === 'stream') await this.streamOnce(signal); + else await this.pollOnce(signal); + if (signal.aborted) return; + // A returned poll is a current answer, even a 304. Stream successes are + // recorded in `apply`. + this.recordSuccess(); + // `pollIntervalSeconds` already spaces the requests, so a completed poll + // also starts the backoff over. + this.backoffAttempt = 0; + } catch (cause) { + if (signal.aborted) return; + if (cause instanceof FatalTransportError) { + this.giveUp(cause.message); + return; + } + if (!(cause instanceof RecoverableTransportError)) { + this.giveUp(`unexpected error in skill delivery: ${cause instanceof Error ? cause.message : String(cause)}`); + return; + } + if (cause instanceof StaleRequestStateError) { + // With no basis or etag to drop, the 400 is fatal. Otherwise drop them + // and request a full transfer once. + if (this.basis === null && this.etag === null) { + this.giveUp(cause.message); + return; + } + this.basis = null; + this.etag = null; + this.etagBasis = null; + } + // A routine stream recycle (see `dispatch`) reconnects without counting + // as a failure. + if (!cause.expected) { + this.failures += 1; + this.reader.diagnostics.connectionFailures = this.failures; + this.reader.diagnostics.lastError = cause.message; + } + // Every reconnect advances the step, a recycle included. Only a stream + // that stayed open long enough starts it over. + if (this.connectedAt !== null && performance.now() - this.connectedAt >= this._backoffResetIntervalMs) { + this.backoffAttempt = 0; + } + this.backoffAttempt += 1; + const requested = cause.retryAfterMs; + const initialBackoffMs = secondsToMs(this.initialBackoffSeconds, 1); + const maxBackoffMs = secondsToMs(this.maxBackoffSeconds, 1); + const delay = Math.min( + requested !== null && Number.isFinite(requested) + ? // Floor at `initialBackoffSeconds` so `Retry-After: 0` cannot + // cause a tight reconnect loop. + Math.max(requested, initialBackoffMs) + : backoffDelayMs(this.backoffAttempt, initialBackoffMs, maxBackoffMs), + // Cap at `maxBackoffSeconds` even if `Retry-After` asks for more. + maxBackoffMs, + ); + if (!cause.expected) { + warn(`Skill delivery failed (${cause.message}); retrying in ${Math.round(delay)}ms`); + } else { + // A routine recycle: nothing above debug. + debug(`Skill delivery reconnecting (${cause.message}) in ${Math.round(delay)}ms`); + } + await sleep(delay, signal); + continue; + } + + if (this.mode === 'poll') await sleep(secondsToMs(this.pollIntervalSeconds, 1), signal); + } + } + + private recordSuccess(): void { + this.failures = 0; + this.reader.diagnostics.connectionFailures = 0; + } + + private giveUp(reason: string): void { + this.failedReason = reason; + this.reader.diagnostics.lastError = reason; + error( + `Skill delivery has stopped and will not retry: ${reason}. The store keeps serving the last content it ` + + 'received, and skills will not update until delivery runs again: call start() on this store once the ' + + 'cause is fixed, or restart the process.', + ); + // Release `waitForSkills` callers now; they resolve `false`. + this.releaseWaiters(); + } + + private apply(name: string, data: unknown): TransferOutcome { + const outcome = this.reader.handle(name, data); + // A commit or a `none` intent is a completed exchange. Counting `none` keeps + // a stream for an unchanging environment from reading as failing. Neither + // resets the backoff step; see `backoffAttempt`. + if (outcome.healthy || outcome.committed) this.reachedServer = true; + if (outcome.healthy) this.recordSuccess(); + if (outcome.committed) { + if (outcome.basis) this.basis = outcome.basis; + this.recordSuccess(); + this.markFirstPayload(); + if (outcome.changes && outcome.changes.length > 0) this.notify(outcome.changes); + } + return outcome; + } + + private dispatch(outcome: TransferOutcome): void { + if (outcome.fatal) throw new FatalTransportError(outcome.fatal); + if (outcome.disconnect) { + // A goodbye is routine only after a completed exchange; otherwise it + // counts as a failure, so a server that only says goodbye is backed off. + const expected = outcome.expected === true && this.reachedServer; + throw new RecoverableTransportError(outcome.disconnect, null, expected); + } + } + + private async pollOnce(signal: AbortSignal): Promise { + const basis = this.basis; + // Send the etag only with the basis it was issued for; a 304 to a stale pair + // would describe the previous request. + const etag = this.etagBasis === basis ? this.etag : null; + const result = await this.requester.poll(basis, etag, signal); + if (result.notModified) { + // A 304 confirms the payload this store holds; it cannot establish one. + // `isInitialized()` stays false until something commits, so a store that + // has received nothing never authorizes a prune of the files on disk. `run` + // counts the poll as a healthy answer. + return; + } + let completed = false; + for (const [name, data] of result.events) { + const outcome = this.apply(name, data); + completed = completed || outcome.committed === true || outcome.healthy === true; + this.dispatch(outcome); + } + if (!completed) { + // An etag describes the body it came with, so it is adopted only when that + // body is also what the store now holds: a commit, or a `none` intent. A + // transfer this SDK could not apply is neither, and keeping its etag would + // let the next 304 confirm content never applied. Any etag already held + // stays valid, so it is left alone, not cleared. + return; + } + this.etag = result.etag; + this.etagBasis = basis; + } + + private async streamOnce(signal: AbortSignal): Promise { + const events = await this.requester.stream(this.basis, signal); + this.connectedAt = performance.now(); + for await (const [name, data] of events) { + if (signal.aborted) return; + this.dispatch(this.apply(name, data)); + } + // A stream that ends without a goodbye is a dropped connection. + throw new RecoverableTransportError('the FDv2 stream closed unexpectedly'); + } +} diff --git a/packages/client/src/skills-fs.ts b/packages/client/src/skills-fs.ts new file mode 100644 index 00000000..3e205134 --- /dev/null +++ b/packages/client/src/skills-fs.ts @@ -0,0 +1,1158 @@ +/** + * Agent Skills — filesystem materialization. + * + * Writes already-verified skill content to disk under a managed root. Retrieval + * and verification live in `skills.ts`; the symlink-refusing primitives live in + * `safe-fs.ts`. + * + * Safety invariants: + * + * - The root is pinned to a descriptor once per reconcile, and every path below + * it is built from {@link PinnedDirectory.address}, so on Linux a root swapped + * mid-run cannot redirect anything. See `safe-fs.ts` for other platforms. + * - Destructive operations only touch paths `/.launchdarkly-skills.json` + * records under a matching key. + * - A corrupt manifest suppresses every destructive action; an incomplete + * retrieval suppresses pruning. + * - Content is re-verified immediately before the write. + * + * These checks are non-relaxable; see `agents.md`, "Relaxing a path or manifest + * check in `skills-fs.ts`". + */ + +import { createHash } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; +import { type FileHandle, lstat, mkdir, open, readdir, realpath, rmdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { + atomicWrite, + directoryAddress, + openDirectoryNoFollow, + openOrCreateDirectory, + tempNamePattern, + unlinkNoFollow, +} from './safe-fs.js'; +import { + allRawObjects, + getStore, + isVerificationFailure, + NO_STORE_MESSAGE, + newestByKey, + type Resolution, + recordMaterialized, + recordRevoked, + referenceTarget, + resolveFromStore, + storeIsInitialized, + verifiedBytes, + verifyRawSkill, +} from './skills-core.js'; +import type { + OnUnavailable, + RawSkillObject, + ReconcileAction, + ReconcileActionKind, + ReconcileReport, + Skill, + SkillReference, + SkillStore, +} from './types.js'; +import { + createReconcileAction, + createReconcileReport, + isValidSkillKey, + isValidSkillVersion, + SKILL_KEY_MAX_LENGTH, +} from './types.js'; + +/** The SDK's record of what it has written under a managed root. */ +export const MANIFEST_FILENAME = '.launchdarkly-skills.json'; + +/** Manifest schema version this release writes, and the highest it can read. */ +export const MANIFEST_VERSION = 1; + +/** + * Hard cap on the manifest read, far above any real manifest. A larger file is + * treated as corrupt rather than read into memory. Not exported from the + * package index. + */ +export const MAX_MANIFEST_BYTES = 8 * 1024 * 1024; + +/** The single file each skill materializes to, under `//`. */ +export const SKILL_FILENAME = 'SKILL.md'; + +/** + * A directory held open for an operation, with its two names (identical except + * on Linux): + * + * - `path`: the real location, used for containment checks and the only name + * shown to callers. + * - `address`: what filesystem calls use. On Linux, `/proc/self/fd/`, which + * resolves from the pinned inode so a renamed directory cannot redirect a call. + */ +type PinnedDirectory = { + readonly path: string; + readonly address: string; + readonly handle: FileHandle; +}; + +/** Pairs a freshly opened directory handle with the two names for it. */ +function pin(realPath: string, handle: FileHandle): PinnedDirectory { + return { path: realPath, address: directoryAddress(handle, realPath), handle }; +} + +/** Prefix on every error describing content that could not be retrieved. */ +const UNAVAILABLE_PREFIX = 'skill retrieval unavailable: '; + +/** + * The single path-component limit on Linux, macOS and Windows. Skill keys may be + * longer, so an over-long key is reported rather than failing with `ENAMETOOLONG`. + */ +const MAX_PATH_COMPONENT_BYTES = 255; + +/** + * Windows reserved device names, which cannot be directory names there. Rejected + * on every platform so a managed root is usable on any OS. The key grammar + * (lowercase, no `.` or `$`) makes forms like `con.txt` unreachable. + */ +const WINDOWS_RESERVED_NAMES: ReadonlySet = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + 'com1', + 'com2', + 'com3', + 'com4', + 'com5', + 'com6', + 'com7', + 'com8', + 'com9', + 'lpt1', + 'lpt2', + 'lpt3', + 'lpt4', + 'lpt5', + 'lpt6', + 'lpt7', + 'lpt8', + 'lpt9', +]); + +/** Options for {@link writeSkills}. */ +export type WriteSkillsOptions = { + /** Remove previously managed skills that are no longer requested. Default `true`. */ + prune?: boolean; + /** Bound on the whole call, in **seconds** (not milliseconds). Non-negative and finite. Default `10`. */ + timeout?: number; + /** How to react to content that could not be retrieved. Default `'keep'`. */ + onUnavailable?: OnUnavailable; +}; + +/** One skill queued for the reconcile: resolved content, or why there is none. */ +type PendingWrite = { + readonly key: string; + readonly skill?: Skill | null; + readonly error?: string | null; +}; + +// ------------------------------------------------------------------------- +// The reconcile entry point +// ------------------------------------------------------------------------- + +/** + * Materializes skills under a managed root at `//SKILL.md`. + * + * `skills` is an array of `Skill` / `SkillReference` / key strings, or `'*'` for + * every skill the store holds. `Skill` values are written as-is; references and + * keys resolve through the configured store. + * + * The reconcile is driven by a manifest, `/.launchdarkly-skills.json`: it + * only overwrites or deletes files the manifest records as written by the SDK. + * + * - `prune`: remove previously managed skills that are no longer requested. This + * is how revocation takes effect. + * - `timeout`: **seconds**, non-negative and finite. Bounds retrieval, writes and + * pruning; checked between steps, not mid-operation. The manifest rewrite + * always runs. + * - `onUnavailable`: `'keep'` reports content that could not be retrieved and + * leaves existing files alone; `'raise'` throws. + * + * Resolves to a `ReconcileReport` listing every outcome. Throws for an invalid + * argument or an unusable root. + * + * Run at most one reconcile per root at a time: concurrent runs can lose each + * other's manifest entries. + */ +export async function writeSkills( + skills: ReadonlyArray | '*', + root: string, + options: WriteSkillsOptions = {}, +): Promise { + const { prune = true, timeout = 10, onUnavailable = 'keep' } = options; + + // Typed as closed sets, but untyped callers can pass anything. + if (onUnavailable !== 'keep' && onUnavailable !== 'raise') { + throw new Error(`onUnavailable must be "keep" or "raise", got ${JSON.stringify(onUnavailable)}`); + } + // `NaN` and `Infinity` both pass `< 0` and would leave the deadline unbounded + // (or, for `NaN`, inconsistently expired). + if (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 0) { + // `JSON.stringify` would show `NaN` and `Infinity` as `null`. + const shown = typeof timeout === 'number' ? String(timeout) : JSON.stringify(timeout); + throw new Error(`timeout must be a non-negative, finite number of seconds, got ${shown}`); + } + + // Validated before the root is resolved, so a failed call creates no directory. + if (typeof skills === 'string' && skills !== '*') { + throw new Error(`writeSkills takes an array of skills or the literal "*"; got ${JSON.stringify(skills)}`); + } + + const deadline = performance.now() + timeout * 1000; + const rootPath = await resolveRoot(root); + + // Pinned once and held for the whole reconcile. If the root was swapped since + // `resolveRoot` validated it, the open fails: reported, not thrown. + let rootHandle: FileHandle; + try { + rootHandle = await openDirectoryNoFollow(rootPath); + } catch (error) { + return createReconcileReport([runError(`the skills root could not be pinned: ${messageOf(error)}`)]); + } + + try { + return await reconcile(pin(rootPath, rootHandle), skills, { prune, timeout, onUnavailable, deadline }); + } finally { + await rootHandle.close().catch(() => undefined); + } +} + +/** Everything `writeSkills` does once the managed root is pinned. */ +async function reconcile( + root: PinnedDirectory, + skills: ReadonlyArray | '*', + options: { prune: boolean; timeout: number; onUnavailable: OnUnavailable; deadline: number }, +): Promise { + const { prune, timeout, onUnavailable, deadline } = options; + const { manifest, error: manifestError } = await loadManifest(root); + const entries: Record = (manifest.entries as Record) ?? {}; + + const actions: ReconcileAction[] = []; + // Run-level failure: there is no single skill key to hang it off. + if (manifestError !== null) actions.push(runError(manifestError)); + + const { requests, incomplete: retrievalIncomplete } = await resolveRequests(skills, deadline, onUnavailable); + + const { actions: written, timedOut } = await writeAll(root, requests, entries, deadline, timeout); + actions.push(...written); + const incomplete = retrievalIncomplete || timedOut; + + // Remove temp files a killed reconcile left behind, before pruning so they + // cannot block its `rmdir`. Skipped on a corrupt manifest or expired deadline. + if (manifestError === null && performance.now() < deadline) { + actions.push(...(await sweepOrphanTemps(root, sweepableKeys(requests, entries)))); + } + + // Prune only when the SDK knows both what it owns (manifest intact) and what is + // current (retrieval and writes completed). + if (prune && manifestError === null && !incomplete) { + actions.push(...(await pruneEntries(root, entries, new Set(requests.map((r) => r.key)), deadline, timeout))); + } + + if (manifestError === null) actions.push(...(await rewriteManifest(root, manifest, entries))); + + return createReconcileReport(actions); +} + +/** A failure belonging to the run rather than to one skill (empty key). */ +function runError(message: string): ReconcileAction { + return createReconcileAction({ key: '', action: 'error', error: message }); +} + +/** A {@link directoryAddress} prefix as it appears inside an `errno` message. */ +const DESCRIPTOR_ADDRESS_IN_MESSAGE = /\/proc\/self\/fd\/\d+\//g; + +/** + * An error's message with `/proc/self/fd//` prefixes stripped, leaving names + * relative to the pinned directory (e.g. `a/SKILL.md`). + */ +function messageOf(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + return raw.replace(DESCRIPTOR_ADDRESS_IN_MESSAGE, ''); +} + +/** + * Reconciles every pending write. + * + * Never aborts: a per-skill failure becomes an `error` action, so the manifest + * rewrite still records every file already written. + */ +async function writeAll( + root: PinnedDirectory, + requests: readonly PendingWrite[], + entries: Record, + deadline: number, + timeout: number, +): Promise<{ actions: ReconcileAction[]; timedOut: boolean }> { + const actions: ReconcileAction[] = []; + let timedOut = false; + + for (const request of requests) { + if (!request.skill) { + actions.push( + createReconcileAction({ + key: request.key, + action: 'error', + error: request.error ?? `skill ${shownKey(request.key)} could not be resolved`, + }), + ); + continue; + } + if (performance.now() >= deadline) { + timedOut = true; + actions.push( + createReconcileAction({ + key: request.key, + action: 'error', + error: `the ${timeout}s timeout was exhausted before skill '${request.key}' could be written`, + }), + ); + continue; + } + try { + actions.push(await writeOne(root, request.skill, entries)); + } catch (error) { + // Safety net: an unexpected filesystem error must not abort the loop. + actions.push( + createReconcileAction({ + key: request.skill.key, + action: 'error', + version: request.skill.version, + error: `skill '${request.skill.key}' could not be reconciled: ${messageOf(error)}`, + }), + ); + } + } + + return { actions, timedOut }; +} + +/** + * Rebuilds `value` with object keys sorted, matching the manifest the other + * LaunchDarkly AI SDKs write so a shared root does not churn. + */ +function sortedForSerialization(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortedForSerialization); + if (typeof value !== 'object' || value === null) return value; + const source = value as Record; + const sorted: Record = {}; + for (const key of Object.keys(source).sort()) sorted[key] = sortedForSerialization(source[key]); + return sorted; +} + +/** + * Non-ASCII UTF-16 code units, to be escaped as `\uXXXX` (astral characters + * become surrogate pairs). + */ +const NON_ASCII = /[\u0080-\uffff]/g; + +/** + * Serializes the manifest in the on-disk form shared with the other LaunchDarkly + * AI SDKs: two-space indent, sorted keys, non-ASCII escaped as `\uXXXX`, and no + * trailing newline. + */ +function serializeManifest(manifest: Record): Buffer { + const json = JSON.stringify(sortedForSerialization(manifest), null, 2); + return Buffer.from( + json.replace(NON_ASCII, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`), + 'utf-8', + ); +} + +/** Writes the updated manifest. Returns an error action, or nothing. */ +async function rewriteManifest( + root: PinnedDirectory, + manifest: Record, + entries: Record, +): Promise { + const updated = { ...manifest, manifestVersion: MANIFEST_VERSION, entries }; + try { + // Inside the guard: unknown fields are round-tripped, so a deeply nested + // planted field can throw here. + const serialized = serializeManifest(updated); + await atomicWrite(root.address, MANIFEST_FILENAME, serialized, root.handle); + } catch (error) { + return [runError(`the skills manifest could not be written: ${messageOf(error)}`)]; + } + return []; +} + +// ------------------------------------------------------------------------- +// Request resolution — content in, or a reason there is none +// ------------------------------------------------------------------------- + +/** Wraps `reason` as a retrieval-unavailable message. */ +function unavailable(reason: string): string { + return `${UNAVAILABLE_PREFIX}${reason}`; +} + +/** A `Skill` and a `SkillReference` are both plain objects, so discriminate structurally. */ +function isSkill(item: Skill | SkillReference | string): item is Skill { + return ( + typeof item === 'object' && + item !== null && + (item as Skill).content instanceof Uint8Array && + typeof (item as Skill).contentHash === 'string' + ); +} + +/** + * Turns the caller's input into one request per skill. + * + * Also reports whether any retrieval was incomplete (no store, an uninitialized + * or throwing store, or an exhausted timeout). An `absent` reference is not + * incomplete; it stays requested, so it is not pruned either. The flag + * suppresses pruning, so an outage never deletes managed files. + */ +async function resolveRequests( + skills: ReadonlyArray | '*', + deadline: number, + onUnavailable: OnUnavailable, +): Promise<{ requests: PendingWrite[]; incomplete: boolean }> { + if (typeof skills === 'string') { + // Already checked by `writeSkills`; repeated here to narrow the type. + if (skills !== '*') { + throw new Error(`writeSkills takes an array of skills or the literal "*"; got ${JSON.stringify(skills)}`); + } + return resolveAll(deadline, onUnavailable); + } + + const requests: PendingWrite[] = []; + let incomplete = false; + for (const item of skills) { + if (isSkill(item)) { + requests.push({ key: item.key, skill: item }); + continue; + } + + const [key, wanted] = referenceTarget(item); + const resolved = resolveReference(key, wanted, deadline); + if (resolved.unavailable) { + incomplete = true; + if (onUnavailable === 'raise') throw new Error(resolved.error ?? unavailable(key)); + } + requests.push({ key, skill: resolved.skill ?? null, error: resolved.error ?? null }); + } + + return { requests, incomplete }; +} + +/** Retrieval could not happen at all; `blocked` is the caller-facing reason. */ +type RetrievalBlocked = { readonly blocked: string }; + +function isBlocked(result: SkillStore | RetrievalBlocked): result is RetrievalBlocked { + return 'blocked' in result; +} + +/** + * The configured store, or why retrieval must not be attempted. + * + * The single gate for both single references and `'*'`. It blocks on an + * exhausted deadline, no configured store, or a store without its initial data; + * each marks the run incomplete, which suppresses pruning. + */ +function availableStore(deadline: number, subject: string): SkillStore | RetrievalBlocked { + if (performance.now() >= deadline) { + return { blocked: unavailable(`the timeout was exhausted before ${subject} could be retrieved`) }; + } + const store = getStore(); + if (store === null) return { blocked: unavailable(NO_STORE_MESSAGE) }; + if (!storeIsInitialized(store)) { + // Before its first delivery a store answers "nothing", which `'*'` would + // read as every skill revoked. + return { + blocked: unavailable( + `the skill store has not received its initial data, so ${subject} could not be retrieved and nothing on ` + + 'disk was changed. Wait for delivery before reconciling: FDv2SkillStore.waitForSkills({ timeoutSeconds }) ' + + 'resolves true once the first payload has arrived.', + ), + }; + } + return store; +} + +/** + * Resolves one reference for the materialization path. + * + * Same core as the accessors, but a blocked store (see {@link availableStore}) is + * reported as unavailable rather than thrown. + */ +function resolveReference(key: string, wantedVersion: number | null, deadline: number): Resolution { + const store = availableStore(deadline, `'${key}'`); + if (isBlocked(store)) return { error: store.blocked, reason: 'store_unavailable', unavailable: true }; + + const resolved = resolveFromStore(store, key, wantedVersion); + if (resolved.unavailable && resolved.error) { + return { error: unavailable(resolved.error), reason: resolved.reason, unavailable: true }; + } + return resolved; +} + +/** + * One run-level retrieval failure — thrown, or reported against the empty key. + * + * Always marks the run incomplete, so nothing is pruned. + */ +function unavailableRun( + error: string, + onUnavailable: OnUnavailable, +): { requests: PendingWrite[]; incomplete: boolean } { + if (onUnavailable === 'raise') throw new Error(error); + return { requests: [{ key: '', error }], incomplete: true }; +} + +/** + * One raw store object as a pending write — verified, or reported as failed. + * + * Unverifiable is not revoked: a failed object keeps its key in the requested + * set, so prune leaves the last known-good copy on disk. + */ +function pendingForRaw(objectKey: string, raw: unknown): PendingWrite { + const skill = verifyRawSkill(raw); + if (skill) return { key: skill.key, skill }; + + // Prefer the object's own key (the on-disk directory name); a custom store may + // use a different map key, such as `key:version`. + const candidate = typeof raw === 'object' && raw !== null ? (raw as RawSkillObject).key : undefined; + const key = isValidSkillKey(candidate) ? candidate : objectKey; + if (!isValidSkillKey(key)) { + // No usable key: report at run level, which `resolveAll` treats as an + // incomplete run. + return { key: '', error: 'the skill store served an object under an invalid key; it was withheld' }; + } + return { + key, + error: `skill '${key}' failed integrity verification and was withheld; the copy already on disk was left alone`, + }; +} + +/** Resolves the `'*'` form — everything the store currently holds. */ +function resolveAll(deadline: number, onUnavailable: OnUnavailable): { requests: PendingWrite[]; incomplete: boolean } { + const store = availableStore(deadline, 'the skill set'); + if (isBlocked(store)) return unavailableRun(store.blocked, onUnavailable); + + // Not via allSkills(), which reports a throwing store as empty — that would + // read as every skill revoked. + const { objects, error } = allRawObjects(store); + if (error !== null) return unavailableRun(unavailable(error), onUnavailable); + + // One object per key, at its newest version: each key has a single path. + const requests = newestByKey(objects).map(({ objectKey, raw }) => pendingForRaw(objectKey, raw)); + // A failure with no key cannot protect its copy on disk, so it suppresses + // pruning for the whole run. + const unattributed = requests.some((request) => !request.skill && request.key === ''); + return { requests, incomplete: unattributed }; +} + +// ------------------------------------------------------------------------- +// The managed root and its manifest +// ------------------------------------------------------------------------- + +/** + * Resolves the managed root once, up front. + * + * Throws for an unusable root. Only the leaf directory is created, so a typo + * cannot create a directory tree. + * + * A caller-error check, not a security boundary: the pin that `writeSkills` takes + * immediately afterwards is what guards against a swap. + */ +async function resolveRoot(root: string): Promise { + if (typeof root !== 'string' || root.length === 0) { + throw new Error('the skills root must be a non-empty path string'); + } + + let info: Awaited> | null = null; + try { + info = await lstat(root); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`the skills root could not be inspected: ${messageOf(error)}`); + } + } + + if (info?.isSymbolicLink()) { + throw new Error(`the skills root must be a real directory, not a symlink: ${root}`); + } + + if (info !== null) { + if (!info.isDirectory()) throw new Error(`the skills root is not a directory: ${root}`); + } else { + const parent = path.dirname(root); + let parentIsDirectory = false; + try { + parentIsDirectory = (await stat(parent)).isDirectory(); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`the parent of the skills root could not be inspected: ${messageOf(error)}`); + } + } + if (!parentIsDirectory) { + throw new Error( + `the parent of the skills root does not exist: ${parent}. writeSkills creates only the leaf directory.`, + ); + } + try { + await mkdir(root); + } catch (error) { + throw new Error(`the skills root could not be created: ${messageOf(error)}`); + } + } + + return realpath(root); +} + +/** + * Loads the manifest. + * + * A manifest that cannot be read or parsed, is not an object, has a + * `manifestVersion` outside `[1, MANIFEST_VERSION]`, or has a malformed `entries` + * map is **corrupt**: the caller then takes no destructive action and leaves the + * file alone. An absent manifest is a fresh root. + * + * Read through the pinned root; a symlink or FIFO at the manifest's name is + * treated as corrupt rather than followed. + */ +async function loadManifest( + root: PinnedDirectory, +): Promise<{ manifest: Record; error: string | null }> { + const fresh = { manifestVersion: MANIFEST_VERSION, entries: {} }; + + let raw: Buffer; + try { + raw = await readRegularFile(path.join(root.address, MANIFEST_FILENAME), MAX_MANIFEST_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { manifest: fresh, error: null }; + return { + manifest: {}, + error: `the skills manifest ${MANIFEST_FILENAME} could not be read: ${messageOf(error)}`, + }; + } + // The read stops one byte past the cap, which is enough to detect an overage. + if (raw.byteLength > MAX_MANIFEST_BYTES) { + return { + manifest: {}, + error: `the skills manifest ${MANIFEST_FILENAME} is larger than the ${MAX_MANIFEST_BYTES} byte cap; refusing every destructive action`, + }; + } + const text = raw.toString('utf-8'); + + // Non-UTF-8 bytes decode as U+FFFD, so they surface as invalid JSON. + let data: unknown; + try { + data = JSON.parse(text); + } catch (error) { + return { + manifest: {}, + error: `the skills manifest ${MANIFEST_FILENAME} is not valid JSON (${messageOf(error)}); refusing every destructive action`, + }; + } + + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return { + manifest: {}, + error: `the skills manifest ${MANIFEST_FILENAME} is not a JSON object; refusing every destructive action`, + }; + } + + const manifest = data as Record; + const version = manifest.manifestVersion; + // Bounded at both ends: a future schema cannot be interpreted, and versions + // below 1 were never written. + if (typeof version !== 'number' || !Number.isInteger(version) || version < 1 || version > MANIFEST_VERSION) { + return { + manifest: {}, + error: `the skills manifest ${MANIFEST_FILENAME} declares manifestVersion ${JSON.stringify(version)}, which this SDK cannot read; refusing every destructive action`, + }; + } + + const { entries } = manifest; + if (typeof entries !== 'object' || entries === null || Array.isArray(entries)) { + return { + manifest: {}, + error: `the skills manifest ${MANIFEST_FILENAME} has a malformed 'entries' map; refusing every destructive action`, + }; + } + + return { manifest, error: null }; +} + +// ------------------------------------------------------------------------- +// Per-skill reconcile +// ------------------------------------------------------------------------- + +/** How much of an untrusted key an error message echoes. */ +const SHOWN_KEY_CHARS = 32; + +/** + * A key as shown in an error message: truncated to {@link SHOWN_KEY_CHARS} and + * JSON-quoted so control characters are escaped. + */ +function shownKey(key: unknown): string { + if (typeof key !== 'string') return JSON.stringify(key) ?? String(key); + return key.length > SHOWN_KEY_CHARS ? `${JSON.stringify(key.slice(0, SHOWN_KEY_CHARS))}...` : JSON.stringify(key); +} + +/** + * Why `key` must not become a directory name under the managed root, or `null`. + * + * Always re-validated before any filesystem call, since a key becomes a path + * component. Shared by the write and prune paths. + */ +function keyRejectionReason(key: unknown): string | null { + if (!isValidSkillKey(key)) { + return `${shownKey(key)} is not a valid skill key (^[a-z0-9][a-z0-9-]*$, at most ${SKILL_KEY_MAX_LENGTH} characters)`; + } + const keyBytes = Buffer.byteLength(key, 'utf-8'); + if (keyBytes > MAX_PATH_COMPONENT_BYTES) { + return `skill key ${shownKey(key)} is ${keyBytes} bytes, over the ${MAX_PATH_COMPONENT_BYTES}-byte limit for a single directory name`; + } + // Checked here rather than in the key grammar, so one such key fails only its + // own write rather than the whole AI Config. + if (WINDOWS_RESERVED_NAMES.has(key)) { + return `skill key '${key}' is a Windows reserved device name, which cannot be a directory name on that platform`; + } + return null; +} + +async function isSymlink(target: string): Promise { + try { + return (await lstat(target)).isSymbolicLink(); + } catch { + return false; + } +} + +async function pathExists(target: string): Promise { + try { + await lstat(target); + return true; + } catch { + return false; + } +} + +/** + * Why `//SKILL.md` must not be touched, or `null`. + * + * Shared by the write and prune paths. Defense in depth: these are path-based + * checks, so the descriptor pin is the real boundary where available. + * `requireDirectory` is set for writes, which need a real directory; a prune only + * needs to not follow a link. `root` is a real path; `skillDir` and `target` are + * addresses, which `realpath` resolves for the containment check. + */ +async function unsafePathReason( + root: string, + skillDir: string, + target: string, + key: string, + requireDirectory: boolean, +): Promise { + if (await isSymlink(skillDir)) return `${key} is a symlink`; + if (requireDirectory && (await pathExists(skillDir))) { + if (!(await stat(skillDir).catch(() => null))?.isDirectory()) return `${key} exists and is not a directory`; + } + if (await isSymlink(target)) return 'the target file is a symlink'; + + let resolvedParent: string; + try { + resolvedParent = await realpath(skillDir); + } catch { + // Not yet on disk: resolve the parent and re-append the component. + resolvedParent = path.join(await realpath(path.dirname(skillDir)), path.basename(skillDir)); + } + if (path.dirname(resolvedParent) !== root) return `it resolves outside the managed root ${root}`; + return null; +} + +/** Reconciles one verified skill against the managed root. */ +async function writeOne( + root: PinnedDirectory, + skill: Skill, + entries: Record, +): Promise { + const key = skill.key; + const failed = (message: string): ReconcileAction => + createReconcileAction({ key, action: 'error', version: skill.version, error: message }); + + const rejection = keyRejectionReason(key); + if (rejection !== null) return failed(`${rejection}; nothing was written`); + if (!isValidSkillVersion(skill.version)) { + return failed( + `skill '${key}' has version ${JSON.stringify(skill.version)}, which is not an integer >= 1; nothing was written`, + ); + } + + // `skillDir` is addressed through the pinned root; `reportedPath` is the + // caller-facing name. + const skillDir = path.join(root.address, key); + const target = path.join(skillDir, SKILL_FILENAME); + const reportedPath = path.join(root.path, key, SKILL_FILENAME); + const relative = `${key}/${SKILL_FILENAME}`; + + const unsafe = await unsafePathReason(root.path, skillDir, target, key, true); + if (unsafe !== null) return failed(`'${relative}' was refused: ${unsafe}; nothing was written`); + + // Re-verify: a Skill can also be constructed directly by a caller. + const verified = verifiedBytes(key, skill.content, skill.contentHash, skill.version); + if (isVerificationFailure(verified)) { + return failed( + `skill '${key}' failed verification immediately before writing: ${verified.reason}; nothing was written`, + ); + } + const { encoded, contentHash } = verified; + + // Overwrite only what the manifest records as the SDK's under this key. + const entry = entries[relative]; + const managed = typeof entry === 'object' && entry !== null && (entry as { key?: unknown }).key === key; + const exists = await pathExists(target); + + let action: ReconcileActionKind = 'written'; + if (exists) { + // Compare bytes before checking the manifest, so a file whose manifest entry + // was never written (process killed mid-run) can be adopted below. Bounded + // at the content length, since the file may be unmanaged and of any size. + let onDisk: Buffer; + try { + onDisk = await readRegularFile(target, encoded.byteLength); + } catch (error) { + // A failed read must never become an overwrite. + return failed( + `'${relative}' exists but could not be read (${messageOf(error)}); refusing to overwrite a file whose current content is unknown`, + ); + } + + if (createHash('sha256').update(onDisk).digest('hex') === contentHash) { + // Adoption: a file byte-identical to the resolved content is recorded as + // managed even without a manifest entry. Only exact matches are adopted. + // Nothing was written, so `writtenAt` is kept if already set. + updateEntry(entries, relative, skill, contentHash, false); + recordMaterialized(key, encoded.byteLength, contentHash, 'skipped_current'); + return createReconcileAction({ key, action: 'skipped_current', version: skill.version, path: reportedPath }); + } + + if (!managed) { + return failed( + `'${relative}' exists but the manifest does not record it as managed under key '${key}'; refusing to overwrite a file this SDK did not write`, + ); + } + // Stale version or local tampering — LD-resolved content wins. + action = 'updated'; + } + + const writeError = await writeThroughPinnedDirectory(skillDir, encoded, key, relative); + if (writeError !== null) return failed(writeError); + + updateEntry(entries, relative, skill, contentHash, true); + recordMaterialized(key, encoded.byteLength, contentHash, action); + return createReconcileAction({ key, action, version: skill.version, path: reportedPath }); +} + +/** + * Reads `target`, refusing anything that is not a regular file. + * + * - `O_NONBLOCK`: opening a FIFO with no writer would otherwise hang forever. + * - `O_NOFOLLOW`: refuses a trailing symlink. + * - The type check uses `stat` on the handle, not the path. + * + * Reads at most `maxBytes + 1` bytes; the extra byte keeps a longer file from + * hashing equal to its prefix. `maxBytes` is required so no call site can read + * unbounded. + * + * Throws for anything the caller must turn into a refusal. + */ +async function readRegularFile(target: string, maxBytes: number): Promise { + const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0); + const handle = await open(target, flags); + try { + if (!(await handle.stat()).isFile()) throw new Error('the path is not a regular file'); + // Buffer sized to the bound, not the file; looped since `read` may return short. + const limit = maxBytes + 1; + const buffer = Buffer.alloc(limit); + let filled = 0; + while (filled < limit) { + const { bytesRead } = await handle.read(buffer, filled, limit - filled, filled); + if (bytesRead === 0) break; + filled += bytesRead; + } + return buffer.subarray(0, filled); + } finally { + await handle.close().catch(() => undefined); + } +} + +/** + * Writes `SKILL.md`. Returns a failure reason, or `null` on success. + * + * Opens (or creates) and pins the skill directory, then writes relative to that + * handle so a swapped directory name cannot redirect the write. + */ +async function writeThroughPinnedDirectory( + skillDir: string, + encoded: Uint8Array, + key: string, + relative: string, +): Promise { + let handle: Awaited>; + try { + handle = await openOrCreateDirectory(skillDir); + } catch (error) { + return `'${relative}' was refused: the directory for skill '${key}' could not be opened: ${messageOf(error)}`; + } + + try { + await atomicWrite(directoryAddress(handle, skillDir), SKILL_FILENAME, encoded, handle); + } catch (error) { + return `'${relative}' could not be written: ${messageOf(error)}`; + } finally { + await handle.close().catch(() => undefined); + } + return null; +} + +/** + * Records a managed path in the manifest. + * + * Merges into any existing entry so fields written by a newer SDK release + * survive. `sha256` and `writtenAt` are informational; currency is always decided + * by hashing the bytes on disk. `touch` refreshes `writtenAt`. + */ +function updateEntry( + entries: Record, + relative: string, + skill: Skill, + contentHash: string, + touch: boolean, +): void { + const existing = entries[relative]; + const entry: Record = + typeof existing === 'object' && existing !== null && !Array.isArray(existing) + ? { ...(existing as Record) } + : {}; + entry.key = skill.key; + entry.version = skill.version; + entry.sha256 = contentHash; + if (touch || !('writtenAt' in entry)) entry.writtenAt = utcTimestamp(); + entries[relative] = entry; +} + +function utcTimestamp(): string { + return `${new Date().toISOString().slice(0, 19)}Z`; +} + +// ------------------------------------------------------------------------- +// Pruning — how revocation takes effect +// ------------------------------------------------------------------------- + +/** + * A prune refusal. + * + * `version` comes from the untrusted manifest, so an invalid one is reported as + * `null`, the same as for a `removed` action. + */ +function pruneError(key: string, message: string, version: unknown = null): ReconcileAction { + return createReconcileAction({ + key, + action: 'error', + version: isValidSkillVersion(version) ? version : null, + error: message, + }); +} + +/** + * Removes managed skills that are no longer requested. + * + * This is how revocation takes effect. The deadline is checked per entry; an + * entry left unpruned is reported as an error and stays in the manifest for the + * next reconcile. + */ +async function pruneEntries( + root: PinnedDirectory, + entries: Record, + requested: ReadonlySet, + deadline: number, + timeout: number, +): Promise { + const actions: ReconcileAction[] = []; + + for (const [relative, entry] of Object.entries(entries)) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue; + const record = entry as Record; + const { key } = record; + if (typeof key !== 'string' || requested.has(key)) continue; + + if (performance.now() >= deadline) { + actions.push( + pruneError( + key, + `the ${timeout}s timeout was exhausted before '${relative}' could be pruned; it was left in place`, + record.version, + ), + ); + continue; + } + + // Only a manifest path this SDK could have written is removable. + if (keyRejectionReason(key) !== null || relative !== `${key}/${SKILL_FILENAME}`) { + actions.push( + pruneError( + key, + `manifest entry '${relative}' does not name a path this SDK could own under key '${key}'; it was left in place`, + record.version, + ), + ); + continue; + } + + try { + actions.push(await pruneOne(root, relative, key, entries)); + } catch (error) { + actions.push(pruneError(key, `'${relative}' could not be removed: ${messageOf(error)}`, record.version)); + } + } + + return actions; +} + +/** Removes one managed skill file, and its directory when that empties it. */ +async function pruneOne( + root: PinnedDirectory, + relative: string, + key: string, + entries: Record, +): Promise { + const skillDir = path.join(root.address, key); + const target = path.join(skillDir, SKILL_FILENAME); + const reportedPath = path.join(root.path, key, SKILL_FILENAME); + const version = (entries[relative] as Record).version; + + const unsafe = await unsafePathReason(root.path, skillDir, target, key, false); + if (unsafe !== null) return pruneError(key, `'${relative}' was not removed: ${unsafe}`, version); + + let removedFromDisk = false; + if (await pathExists(target)) { + let handle: Awaited>; + try { + handle = await openDirectoryNoFollow(skillDir); + } catch (error) { + return pruneError(key, `'${relative}' was not removed: ${messageOf(error)}`, version); + } + try { + await unlinkNoFollow(directoryAddress(handle, skillDir), SKILL_FILENAME, handle); + } catch (error) { + return pruneError(key, `'${relative}' could not be removed: ${messageOf(error)}`, version); + } finally { + await handle.close().catch(() => undefined); + } + removedFromDisk = true; + // Through the pinned root; rmdir refuses a symlink and a non-empty + // directory, and a non-empty directory is expected, so failure is ignored. + await rmdir(skillDir).catch(() => undefined); + } + + delete entries[relative]; + + // Raw manifest values: `recordRevoked` validates them itself. + if (removedFromDisk) recordRevoked(key, version); + + return createReconcileAction({ + key, + action: 'removed', + version: isValidSkillVersion(version) ? version : null, + path: reportedPath, + }); +} + +// ------------------------------------------------------------------------- +// Orphaned temp files +// ------------------------------------------------------------------------- + +/** + * Every key whose directory may hold an orphaned temp file: the requested set plus + * every manifest key (including those prune is about to remove). + */ +function sweepableKeys(requests: readonly PendingWrite[], entries: Record): Set { + const keys = new Set(); + for (const request of requests) { + // The empty key is the run-level sentinel, not a directory name. + if (request.key !== '') keys.add(request.key); + } + for (const entry of Object.values(entries)) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) continue; + const { key } = entry as Record; + if (typeof key === 'string') keys.add(key); + } + return keys; +} + +/** Sweeps each key's directory. Failures are reported, never thrown. */ +async function sweepOrphanTemps(root: PinnedDirectory, keys: ReadonlySet): Promise { + const actions: ReconcileAction[] = []; + for (const key of keys) actions.push(...(await sweepSkillDirectory(root, key))); + return actions; +} + +/** + * Removes temp files a killed reconcile left behind under `//`. + * + * This is the only removal of a file the manifest does not list, so it is + * tightly bounded: a valid key's directory only, only names + * {@link tempNamePattern} matches, only regular files, all via the pinned handle. + * A missing or non-directory path is skipped; only a failed removal is reported. + */ +async function sweepSkillDirectory(root: PinnedDirectory, key: string): Promise { + if (keyRejectionReason(key) !== null) return []; + + const skillDir = path.join(root.address, key); + const pattern = tempNamePattern(SKILL_FILENAME); + + let handle: Awaited>; + try { + handle = await openDirectoryNoFollow(skillDir); + } catch { + return []; + } + + // List and unlink through the same pinned handle. + const inside = directoryAddress(handle, skillDir); + + const actions: ReconcileAction[] = []; + try { + let names: string[]; + try { + names = await readdir(inside); + } catch { + return []; + } + for (const name of names) { + if (!pattern.test(name)) continue; + try { + // Never a symlink and never a directory, whatever the name says. + if (!(await lstat(path.join(inside, name))).isFile()) continue; + await unlinkNoFollow(inside, name, handle); + } catch (error) { + actions.push( + createReconcileAction({ + key, + action: 'error', + error: `an orphaned temporary file '${key}/${name}' could not be removed: ${messageOf(error)}`, + }), + ); + } + } + } finally { + await handle.close().catch(() => undefined); + } + return actions; +} diff --git a/packages/client/src/skills-watch.ts b/packages/client/src/skills-watch.ts new file mode 100644 index 00000000..e1c430e5 --- /dev/null +++ b/packages/client/src/skills-watch.ts @@ -0,0 +1,241 @@ +/** + * Agent Skills — keep skills on disk in sync as delivery changes. + * + * `writeSkills` is a one-shot reconcile of what the store holds now. + * `watchSkills` re-runs it whenever the store reports a change, so with `'*'` a + * skill revoked in LaunchDarkly is removed from disk within a debounce interval + * rather than at the next restart. With an explicit list, an `absent` skill stays + * requested (an `error` action, not pruned), and flag changes are not watched. + * + * `onUnavailable: 'keep'` remains the default, so an outage never deletes the + * application's skill files. + */ + +import { getStore, SKILL_OBJECT_KIND } from './skills-core.js'; +import { type WriteSkillsOptions, writeSkills } from './skills-fs.js'; +import type { ReconcileReport, Skill, SkillReference, SkillStore } from './types.js'; + +/** + * Default debounce, in milliseconds: how long to wait after a change before + * reconciling, so a payload of many skills triggers one reconcile, not one each. + */ +export const SKILLS_DEFAULT_DEBOUNCE_MS = 500; + +/** Options for {@link watchSkills}: every {@link WriteSkillsOptions} field, plus these. */ +export type WatchSkillsOptions = WriteSkillsOptions & { + /** + * Milliseconds to wait after a change before reconciling. Must be a + * non-negative finite number. Default {@link SKILLS_DEFAULT_DEBOUNCE_MS}. + */ + debounceMs?: number; + /** + * Called with each re-reconcile's report (not the initial one). May be + * `async`; a throw or rejection is logged and does not stop the watcher. + */ + onReconcile?: (report: ReconcileReport) => unknown; +}; + +function error(message: string): void { + // biome-ignore lint/suspicious/noConsole: this package has no logger abstraction; a failing reconcile must be visible + console.error(`[LaunchDarkly] ${message}`); +} + +/** + * A running watch. Returned by {@link watchSkills}; stop it with `close`. + * + * **One watcher per root.** Don't point two watchers at the same root or run + * `writeSkills` on a watched root concurrently: interleaved reconciles can lose + * manifest entries. The watcher serialises its own reconciles only. + */ +export class SkillWatcher { + private timer: ReturnType | null = null; + private running: Promise = Promise.resolve(); + private pending = false; + private closed = false; + private completed = 0; + + constructor( + private readonly store: SkillStore, + private readonly request: ReadonlyArray | '*', + private readonly root: string, + private readonly options: WriteSkillsOptions, + private readonly debounceMs: number, + private readonly onReconcile?: (report: ReconcileReport) => unknown, + ) {} + + /** + * The store's change listener. Schedules a reconcile; runs nothing inline. + * + * Called from the delivery task, so it must not block on filesystem work. + * The argument is ignored: any change triggers a full reconcile. + */ + readonly notify = (): void => { + if (this.closed) return; + // Restart the timer, so a burst collapses into one reconcile of the settled state. + if (this.timer !== null) clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.timer = null; + this.schedule(); + }, this.debounceMs); + (this.timer as unknown as { unref?: () => void }).unref?.(); + }; + + private schedule(): void { + if (this.closed) return; + if (this.pending) return; + this.pending = true; + // Chain, never overlap: concurrent reconciles of one root lose manifest entries. + this.running = this.running.then(async () => { + this.pending = false; + await this.reconcileOnce(); + }); + } + + /** + * Runs the initial reconcile on the watcher's chain and returns its report. + * Called once by {@link watchSkills}. + * + * A change delivered meanwhile queues behind it. Errors propagate, and the run + * does not count toward {@link reconciles}. + */ + async runInitial(): Promise { + const run = this.running.then(() => writeSkills(this.request, this.root, this.options)); + // Keep the chain alive past a rejection; the caller still gets `run`. + this.running = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async reconcileOnce(): Promise { + if (this.closed) return; + let report: ReconcileReport; + try { + report = await writeSkills(this.request, this.root, this.options); + } catch (cause) { + // Log and keep watching; dying here would silently stop revocations. + error( + `A skill re-reconcile threw; the watcher continues: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + return; + } + this.completed += 1; + if (this.onReconcile) { + try { + // Awaited inside the try, so an async rejection is logged too. + await Promise.resolve(this.onReconcile(report)); + } catch (cause) { + error(`A watchSkills callback threw: ${cause instanceof Error ? cause.message : String(cause)}`); + } + } + } + + /** Number of re-reconciles completed, excluding the initial one. */ + get reconciles(): number { + return this.completed; + } + + /** + * Stops watching. Idempotent; leaves files on disk as they are. + * + * Detaches from the store (when it has `removeListener`; a throw there is + * logged, not rethrown), then awaits any in-flight reconcile rather than + * interrupting it mid-write. + */ + async close(): Promise { + if (this.closed) { + await this.running; + return; + } + this.closed = true; + if (this.timer !== null) { + clearTimeout(this.timer); + this.timer = null; + } + this.detach(); + await this.running; + } + + private detach(): void { + if (typeof this.store.removeListener !== 'function') return; + try { + this.store.removeListener(SKILL_OBJECT_KIND, this.notify); + } catch (cause) { + error( + `The skill store's removeListener threw while a watcher was closing; the watcher is closed regardless: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + ); + } + } +} + +/** + * Reconciles now, then re-reconciles whenever delivery changes. + * + * Takes the same arguments as `writeSkills`, plus `debounceMs` and `onReconcile` + * (see {@link WatchSkillsOptions}). + * + * Errors from the initial `writeSkills` (e.g. a bad root) propagate, so you can + * fail fast exactly as with `writeSkills`. If the store has no `removeListener`, + * a closed watcher stays registered with the store for the store's lifetime. + * + * @returns The initial reconcile's report and a {@link SkillWatcher} to close + * when done: + * + * ```ts + * const { report, watcher } = await watchSkills('*', '.claude/skills'); + * try { + * // ... + * } finally { + * await watcher.close(); + * } + * ``` + * + * @throws Error if no store is configured, or the store has no `addListener` + * (use `writeSkills` for a one-shot reconcile). + * @throws Error if `debounceMs` is negative, `NaN`, or infinite. + */ +export async function watchSkills( + skills: ReadonlyArray | '*', + root: string, + options: WatchSkillsOptions = {}, +): Promise<{ report: ReconcileReport; watcher: SkillWatcher }> { + const store = getStore(); + if (store === null) { + throw new Error('watchSkills needs a configured skill store. Configure one with setSkillStore(store).'); + } + if (typeof store.addListener !== 'function') { + throw new Error( + 'watchSkills needs a skill store that implements addListener(kind, fn); the configured store does not, so ' + + 'delivery changes cannot be observed. Use writeSkills for a one-shot reconcile, or configure a store with a ' + + 'delivery transport (FDv2SkillStore).', + ); + } + + // A bare `< 0` check misses `NaN`, which `setTimeout` treats as ~0 (no debouncing). + const { debounceMs = SKILLS_DEFAULT_DEBOUNCE_MS, onReconcile, ...writeOptions } = options; + if (typeof debounceMs !== 'number' || !Number.isFinite(debounceMs) || debounceMs < 0) { + // `String`, because `JSON.stringify(NaN)` is `null`. + const shown = typeof debounceMs === 'number' ? String(debounceMs) : JSON.stringify(debounceMs); + throw new Error(`debounceMs must be a non-negative, finite number of milliseconds, got ${shown}`); + } + + const watcher = new SkillWatcher(store, skills, root, writeOptions, debounceMs, onReconcile); + + // Attach the listener before the initial reconcile, so a change delivered + // during it is still seen (nothing re-reconciles on a timer). + store.addListener(SKILL_OBJECT_KIND, watcher.notify); + + // A failed initial reconcile throws to the caller, who gets no watcher to + // close, so detach the listener here. + let report: ReconcileReport; + try { + report = await watcher.runInitial(); + } catch (cause) { + await watcher.close(); + throw cause; + } + return { report, watcher }; +} diff --git a/packages/client/src/skills.ts b/packages/client/src/skills.ts new file mode 100644 index 00000000..89edd3a4 --- /dev/null +++ b/packages/client/src/skills.ts @@ -0,0 +1,389 @@ +/** + * Agent Skills — reference discovery and content accessors. + * + * - `skillRefs` reads the skill references a resolved AI Config carries. + * - `getSkill`, `getSkillResult`, `getSkills` and `allSkills` return verified + * skill content from the configured store. + * - `InMemorySkillStore` is a simple store for local development and tests. + * + * Writing skills to disk lives in `skills-fs.ts` (`writeSkills`). + */ + +import { + allRawObjects, + clearState, + logWithholdingSummary, + newestByKey, + referenceTarget, + requireStore, + resolveFromStore, + SKILL_OBJECT_KIND, + setEmitter, + setStore, + verifyRawSkill, +} from './skills-core.js'; +import type { AiConfigRep, RawSkillObject, Skill, SkillOutcome, SkillReference, SkillStore } from './types.js'; +import { + createSkillOutcome, + createSkillReference, + isValidSkillKey, + isValidSkillVersion, + SKILL_KEY_MAX_LENGTH, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Injection points +// --------------------------------------------------------------------------- +// +// Used by `setSkillStore` and `shutdown` (and tests). The state itself lives in +// `skills-core.ts`, so there is exactly one store and one emitter. + +/** Installs the configured skill store. Called by `setSkillStore`. */ +export function _setStore(store: SkillStore): void { + setStore(store); +} + +/** Test helper: replaces the no-op telemetry emitter. */ +export function _setEmitterForTesting(emitter: { + record(signal: string, properties: Record): void; +}): void { + setEmitter(emitter); +} + +/** Clears the configured store and emitter. Called by `shutdown`. */ +export function _clearState(): void { + clearState(); +} + +/** + * Sets the store the Agent Skills accessors read from. Without one, they throw. + * + * Applies on every call, including after `initClient`, so a lazily initialized + * client can be given a store later; a second call replaces the first store. A + * nullish argument is ignored and never clears the configured store — use + * `shutdown()` for that. + * + * Replacing a store does not close the previous one; close it yourself if it + * holds a connection. A running `watchSkills` keeps listening to the store it + * started with, so close the watcher and start a new one to follow the + * replacement. + * + * ```ts + * import { FDv2SkillStore, setSkillStore } from '@launchdarkly/ai-server/experimental'; + * + * setSkillStore(new FDv2SkillStore(process.env.LD_SDK_KEY!).start()); + * ``` + * + * @throws TypeError if `store` is not nullish and has no `getObject` and + * `allObjects` methods. + */ +export function setSkillStore(store: SkillStore | null | undefined): void { + if (store == null) return; + const missing = (['getObject', 'allObjects'] as const).filter( + (name) => typeof (store as unknown as Record)[name] !== 'function', + ); + if (missing.length > 0) { + const kind = typeof store === 'object' ? (store.constructor?.name ?? 'Object') : typeof store; + throw new TypeError(`setSkillStore needs a SkillStore; ${kind} has no ${missing.join(' or ')} method.`); + } + _setStore(store); +} + +/** + * An in-memory skill store, for local development, tests, and + * bring-your-own-content. + * + * - Holds raw skill objects verbatim and does no validation; the accessors + * verify everything they return. + * - Can hold several versions of one key. `getObject` selects on + * `(key, version)`; an omitted version means the newest held. + * - An object with an invalid `version` is still stored (under its key alone), + * so the accessors report it as an integrity failure rather than as absent. + */ +export class InMemorySkillStore implements SkillStore { + // `Map`s, not plain objects: keys come off the wire, and `__proto__` or + // `constructor` must behave like any other key. + /** Well-formed objects, `key` -> `version` -> object. */ + private readonly versions = new Map>(); + + /** Objects carrying no usable version, under their key alone. */ + private readonly loose = new Map(); + + private readonly listeners = new Map unknown>>(); + + constructor(objects: Record = {}) { + // Own entries only, so a `__proto__` entry is filed like any other key. + for (const [objectKey, raw] of Object.entries(objects)) this.place(objectKey, raw); + } + + /** Files one raw object under its own key, else under `fallbackKey`. */ + private place(fallbackKey: string, raw: RawSkillObject): void { + const own = typeof raw === 'object' && raw !== null ? raw.key : undefined; + const key = typeof own === 'string' ? own : fallbackKey; + const version = typeof raw === 'object' && raw !== null ? raw.version : undefined; + if (isValidSkillVersion(version)) { + const held = this.versions.get(key) ?? new Map(); + held.set(version, raw); + this.versions.set(key, held); + } else { + this.loose.set(key, raw); + } + } + + /** + * Adds or replaces a raw skill object, keyed by its own `key` and `version` + * fields. + * + * A second version of a key is kept alongside the first; the same + * `(key, version)` replaces it. Then calls every `'skill'` listener with the + * raw, unverified object. + * + * @throws Error if `raw` has no string `key`. + */ + put(raw: RawSkillObject): void { + const { key } = raw; + if (typeof key !== 'string') throw new Error("a raw skill object must carry a string 'key'"); + this.place(key, raw); + // Iterate a copy, so a listener may remove itself mid-notification. + for (const listener of [...(this.listeners.get(SKILL_OBJECT_KIND) ?? [])]) listener(raw); + } + + /** + * The raw object held for `key` at `version`, or the newest held when no + * version is given. + * + * If only a malformed entry exists it is served, so verification reports it. + * When well-formed versions exist, a missed pin is a plain miss. + */ + getObject(kind: string, key: string, version?: number | null): RawSkillObject | null { + if (kind !== SKILL_OBJECT_KIND) return null; + const held = this.versions.get(key); + if (held === undefined || held.size === 0) return this.loose.get(key) ?? null; + if (version !== undefined && version !== null) return held.get(version) ?? null; + return held.get(Math.max(...held.keys())) ?? null; + } + + /** + * Every object held, one entry per `(key, version)`. + * + * The record keys are opaque identifiers: don't parse them or assume one entry + * per skill key. + */ + allObjects(kind: string): Record { + if (kind !== SKILL_OBJECT_KIND) return {}; + // Null-prototype, so a `__proto__` key is kept (a dropped entry would read + // as a revocation). + const out: Record = Object.create(null); + for (const [key, held] of this.versions) { + for (const [version, raw] of held) out[`${key}:${version}`] = raw; + } + for (const [key, raw] of this.loose) out[key] = raw; + return out; + } + + /** + * Registers `fn` to be called with each raw object `put` under `kind`. + * + * @throws Error if `kind` is not `'skill'`. This store notifies no other kind, + * and a listener that silently never fires would look like one whose skills + * never changed. + */ + addListener(kind: string, fn: (raw: RawSkillObject) => unknown): void { + if (kind !== SKILL_OBJECT_KIND) { + throw new Error( + `InMemorySkillStore notifies only '${SKILL_OBJECT_KIND}' changes, so a listener on ${JSON.stringify(kind)} ` + + `would never fire. Register it on '${SKILL_OBJECT_KIND}'.`, + ); + } + const existing = this.listeners.get(kind); + if (existing) existing.push(fn); + else this.listeners.set(kind, [fn]); + } + + /** + * Unregisters `fn` from `kind`. + * + * Removes one registration per call. Removing a callable that is not + * registered is a no-op. + */ + removeListener(kind: string, fn: (raw: RawSkillObject) => unknown): void { + const listeners = this.listeners.get(kind); + if (!listeners) return; + const index = listeners.indexOf(fn); + if (index !== -1) listeners.splice(index, 1); + } +} + +// --------------------------------------------------------------------------- +// Reference discovery +// --------------------------------------------------------------------------- + +/** + * Why a present `skills` field is malformed, or `null` when it is valid. One + * malformed reference rejects the whole field, never a partial list. + */ +function skillsFieldRejectionReason(raw: unknown): string | null { + if (!Array.isArray(raw)) return 'skills must be an array of {key, version} objects'; + + for (const [index, entry] of raw.entries()) { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + return `skills[${index}] must be an object with key and version`; + } + const { key, version } = entry as { key?: unknown; version?: unknown }; + if (!isValidSkillKey(key)) { + return `skills[${index}].key must be a string matching ^[a-z0-9][a-z0-9-]*$ of at most ${SKILL_KEY_MAX_LENGTH} characters`; + } + if (!isValidSkillVersion(version)) return `skills[${index}].version must be an integer >= 1`; + } + return null; +} + +/** + * Returns the skill references attached to a resolved AI Config. + * + * Pure: no network, store, or telemetry. Returns `[]` when the config has no + * `skills` field. Typical use: `await getSkills(skillRefs(config))`. + * + * A `config` that is not an object throws, `null` and `undefined` included. + * `inspectConfig` answers `config: null` when the config could not be resolved, + * and that is not a config with no skills: read as one, the pipeline + * `writeSkills(skillRefs(info.config), root)` would prune every skill it + * manages during an outage. Check `info.config` first, or let it throw. + * + * `parseAiConfig` does not validate `skills`, so a malformed field does not + * fail core config calls. It is validated here instead, and rejected whole: + * `writeSkills` with `prune: true` would delete the files of any skill missing + * from the list, so a partial or empty list is never returned for a field that + * is present. + * + * @throws TypeError if `config` is not an object (including `null` and + * `undefined`), or if `skills` is present but is not an array of + * `{ key, version }` objects with a valid key and an integer version >= 1, + * including `skills: null`. + */ +export function skillRefs(config: AiConfigRep | null | undefined): SkillReference[] { + if (typeof config !== 'object' || config === null || Array.isArray(config)) { + throw new TypeError( + `skillRefs needs a resolved AI Config object, got ${ + config === null ? 'null' : Array.isArray(config) ? 'an array' : typeof config + }. A config that could not be resolved has no skill list: returning [] would let writeSkills prune every ` + + 'skill it manages. Check that the config resolved before reading its skills.', + ); + } + + const raw: unknown = config.skills; + if (raw === undefined) return []; + + const rejection = skillsFieldRejectionReason(raw); + if (rejection !== null) throw new TypeError(`Invalid skills field in AI Config: ${rejection}`); + return (raw as Array<{ key: string; version: number }>).map(({ key, version }) => + createSkillReference({ key, version }), + ); +} + +// --------------------------------------------------------------------------- +// Content accessors +// --------------------------------------------------------------------------- + +/** + * Retrieves one verified skill by key. + * + * Skills have no targeting, so there is no context parameter. For the skills a + * given context's AI Config uses, call `getSkills(skillRefs(config))`. + * + * @param key The skill key. + * @param options.version The exact version to return. Omit for the newest + * version the store holds. + * @returns The `Skill`, or `null` if it is missing, not at the requested version, + * or fails verification. Use {@link getSkillResult} to learn which. + * @throws Error if no skill store is configured. + */ +export async function getSkill(key: string, options: { version?: number } = {}): Promise { + return resolveFromStore(requireStore(), key, options.version ?? null).skill ?? null; +} + +/** + * Retrieves one verified skill, reporting *why* when there is none. + * + * Behaves exactly like {@link getSkill} (same lookup, verification, and + * telemetry), but returns a `SkillOutcome` whose `reason` says what happened + * instead of collapsing every failure to `null`. Use it to fail closed on + * tampering while tolerating a missing skill: + * + * ```ts + * const outcome = await getSkillResult('pdf-extraction'); + * if (outcome.reason === 'integrity_failure') throw new Error(`refusing to run: ${outcome.detail}`); + * if (outcome.skill) console.log(outcome.skill.content); + * ``` + * + * `detail` is a human-readable message, safe to log: it never contains skill + * content or filesystem paths. Branch on `reason`, not `detail`. + * + * @throws Error if no skill store is configured. + */ +export async function getSkillResult(key: string, options: { version?: number } = {}): Promise { + const resolved = resolveFromStore(requireStore(), key, options.version ?? null); + return createSkillOutcome({ + skill: resolved.skill ?? null, + reason: resolved.reason, + detail: resolved.error ?? null, + }); +} + +/** + * Retrieves a batch of verified skills. + * + * @param refs `SkillReference` values and/or bare key strings (a string means the + * newest version). + * @returns The skills found, in input order. Entries that are missing, at the + * wrong version, or fail verification are omitted. A warning logs how many + * failed verification; misses are not counted. + * @throws TypeError if `refs` is a single string; pass `[key]` instead. + * @throws Error if no skill store is configured. + */ +export async function getSkills(refs: ReadonlyArray): Promise { + if (typeof refs === 'string') { + // A string is iterable and would be looked up per character. + throw new TypeError( + `getSkills takes a sequence of references; pass [key] rather than a bare string. Got ${JSON.stringify(refs)}.`, + ); + } + + const store = requireStore(); + + const skills: Skill[] = []; + // Only what the store served counts toward the summary: a miss or an outage is + // not a verification failure, and the summary would report it as one. + let served = 0; + for (const ref of refs) { + const [key, wanted] = referenceTarget(ref); + const { skill, reason } = resolveFromStore(store, key, wanted); + if (skill) skills.push(skill); + if (reason === 'ok' || reason === 'integrity_failure') served += 1; + } + logWithholdingSummary('requested skills the store served', served, skills.length); + return skills; +} + +/** + * Retrieves every verified skill the store currently holds. + * + * Returns the newest version of each key. Skills that fail verification are + * omitted, and a warning logs how many. + * + * @throws Error if no skill store is configured. + */ +export async function allSkills(): Promise { + const { objects, error } = allRawObjects(requireStore()); + if (error !== null) return []; + + // The store may hold several versions per key; keep only the newest. + const candidates = newestByKey(objects); + const skills: Skill[] = []; + for (const { raw } of candidates) { + const skill = verifyRawSkill(raw); + if (skill) skills.push(skill); + } + logWithholdingSummary('skills held by the store', candidates.length, skills.length); + return skills; +} diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index c90261c4..ddc5ba2f 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -152,6 +152,15 @@ export type AiConfigRep = { * best-effort fallback. Ignored in streaming mode. */ outputFormat?: Record; + /** + * Optional version-pinned skill references attached to this variation, as + * delivered. `parseAiConfig` does not validate it, so a malformed field never + * fails a core call; read it with `skillRefs` from + * `@launchdarkly/ai-server/experimental`, which does. Typed `unknown` because + * it is unvalidated, and because Agent Skills is experimental, so this core + * type does not name its types. + */ + skills?: unknown; }; type ParseResult = { success: true; data: T } | { success: false; error: { message: string } }; @@ -160,6 +169,255 @@ function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null && !Array.isArray(v); } +// --------------------------------------------------------------------------- +// Agent Skills — value types +// --------------------------------------------------------------------------- + +/** A version-pinned pointer to a skill, as attached to an AI Config variation. */ +export type SkillReference = { + /** Immutable skill key — `^[a-z0-9][a-z0-9-]*$`, at most 256 characters. */ + readonly key: string; + /** Immutable skill version — an integer >= 1. */ + readonly version: number; +}; + +/** + * A single verified `SKILL.md` document. Frozen. + * + * The accessors return a `Skill` only after integrity verification, so `content` + * is exactly the bytes LaunchDarkly delivered and `contentHash` is their sha256. + */ +export type Skill = { + readonly key: string; + readonly version: number; + /** The verbatim bytes. The SDK never decodes or parses them. */ + readonly content: Uint8Array; + /** sha256, lowercase hex, over the verbatim bytes of `content`. */ + readonly contentHash: string; + /** Display name from LaunchDarkly metadata; never parsed from the content. */ + readonly name: string | null; + /** Description from LaunchDarkly metadata; never parsed from the content. */ + readonly description: string | null; +}; + +/** + * The outcomes `getSkillResult` reports. + * + * - `ok` — a verified skill was returned. + * - `absent` — the store does not hold the key. + * - `integrity_failure` — content failed verification and was withheld. Fail + * closed on this one: it can indicate tampering. + * - `store_unavailable` — the store threw. An outage, not a deletion. + * - `wrong_version` — the store holds a different version than the one + * requested, so nothing was returned. + */ +export type SkillOutcomeReason = 'absent' | 'integrity_failure' | 'ok' | 'store_unavailable' | 'wrong_version'; + +/** + * The result of `getSkillResult`: the skill, plus why it was or wasn't returned. + * Frozen. + */ +export type SkillOutcome = { + /** The verified skill; set only when `reason === 'ok'`. */ + readonly skill: Skill | null; + /** Which outcome happened; see {@link SkillOutcomeReason}. */ + readonly reason: SkillOutcomeReason; + /** + * Human-readable message, set for every reason except `'ok'`. + * + * Safe to log: never contains skill content or filesystem paths. Branch on + * `reason`, not on this. + */ + readonly detail: string | null; +}; + +/** + * The per-skill outcomes `writeSkills` reports. + * + * - `written` — the file did not exist and now holds the resolved content. + * - `updated` — a managed file held different bytes and was overwritten. + * - `skipped_current` — the bytes on disk already are the resolved content. + * - `removed` — the skill is no longer managed and is not on disk (whether this + * run deleted it or it was already gone). + * - `error` — the outcome was refused or failed; see `ReconcileAction.error`. + */ +export type ReconcileActionKind = 'written' | 'updated' | 'skipped_current' | 'removed' | 'error'; + +/** How `writeSkills` reacts to content it could not retrieve. */ +export type OnUnavailable = 'keep' | 'raise'; + +/** What `writeSkills` did — or refused to do — for one skill. */ +export type ReconcileAction = { + /** + * The skill key, or `''` for a run-level failure not tied to one skill (for + * example a corrupt or unwritable manifest, or a failed retrieval). Expect `''` + * when grouping a report by key. + */ + readonly key: string; + readonly action: ReconcileActionKind; + readonly version: number | null; + /** Canonical resolved path, when one was determined. */ + readonly path: string | null; + /** Failure detail, set only when `action === 'error'`. */ + readonly error: string | null; +}; + +/** The result of a `writeSkills` run: one action per outcome. */ +export type ReconcileReport = { + readonly actions: readonly ReconcileAction[]; + /** `true` iff no action is an `error`. */ + readonly ok: boolean; + /** The `error` actions, in `actions` order. */ + readonly errors: readonly ReconcileAction[]; +}; + +/** + * A raw skill object as a `SkillStore` serves it, before verification. + * + * Every field is `unknown`: this is untrusted input, and the accessors verify it. + */ +export type RawSkillObject = { + key?: unknown; + version?: unknown; + content?: unknown; + contentHash?: unknown; + name?: unknown; + description?: unknown; + [field: string]: unknown; +}; + +/** + * The interface a source of skill content implements. Structurally typed: pass + * any object with these methods. `FDv2SkillStore` (LaunchDarkly delivery) and + * `InMemorySkillStore` are the built-in implementations. + * + * Everything a store serves is treated as untrusted; the accessors re-verify + * key, version, size, and content hash on every read. + * + * Optional methods: + * - `addListener` / `removeListener` — needed by `watchSkills` (which throws + * without `addListener`); the accessors use neither. `addListener` should throw + * for a `kind` it cannot notify rather than accept a listener that never + * fires. `removeListener` removes one registration and is a no-op if `fn` is + * not registered. + * - `isInitialized` — whether the store has received its initial data. Absent + * means initialized; a throw means not. `writeSkills` prunes nothing while the + * store is uninitialized, so an empty store that hasn't heard yet is not + * mistaken for "every skill was revoked". + */ +export type SkillStore = { + /** + * The object held for `key`, or `null`/`undefined` when there is none. + * + * `version` is the pinned version; omitted or `null` asks for the newest held. + * A store that cannot satisfy a pin should return what it has rather than + * nothing: the accessors withhold the mismatch and report it as + * `wrong_version` rather than `absent`. + */ + getObject(kind: string, key: string, version?: number | null): RawSkillObject | null | undefined; + /** Every object held. Record keys are opaque; identity comes from each object's `key` and `version`. */ + allObjects(kind: string): Record; + addListener?(kind: string, fn: (raw: RawSkillObject) => unknown): void; + removeListener?(kind: string, fn: (raw: RawSkillObject) => unknown): void; + /** Whether the store has received its initial data. Absent means initialized. */ + isInitialized?(): boolean; +}; + +/** Builds a frozen {@link SkillReference}. */ +export function createSkillReference(init: { key: string; version: number }): SkillReference { + return Object.freeze({ key: init.key, version: init.version }); +} + +/** + * Builds a frozen {@link Skill}. + * + * Does **not** verify the content; `writeSkills` re-verifies every skill before + * writing it. + */ +export function createSkill(init: { + key: string; + version: number; + content: Uint8Array; + contentHash: string; + name?: string | null; + description?: string | null; +}): Skill { + // Only the wrapper is frozen (a TypedArray can't be); `content` shares the + // caller's bytes. + return Object.freeze({ + key: init.key, + version: init.version, + content: init.content, + contentHash: init.contentHash, + name: init.name ?? null, + description: init.description ?? null, + }); +} + +/** Builds a frozen {@link SkillOutcome}. `reason` is required. */ +export function createSkillOutcome(init: { + skill?: Skill | null; + reason: SkillOutcomeReason; + detail?: string | null; +}): SkillOutcome { + return Object.freeze({ + skill: init.skill ?? null, + reason: init.reason, + detail: init.detail ?? null, + }); +} + +/** Builds a frozen {@link ReconcileAction}. Internal to the reconcile. */ +export function createReconcileAction(init: { + key: string; + action: ReconcileActionKind; + version?: number | null; + path?: string | null; + error?: string | null; +}): ReconcileAction { + return Object.freeze({ + key: init.key, + action: init.action, + version: init.version ?? null, + path: init.path ?? null, + error: init.error ?? null, + }); +} + +/** Builds a frozen {@link ReconcileReport}, deriving `ok` and `errors`. */ +export function createReconcileReport(actions: readonly ReconcileAction[]): ReconcileReport { + const frozenActions = Object.freeze([...actions]); + const errors = Object.freeze(frozenActions.filter((a) => a.action === 'error')); + // `ok` is defined in terms of `errors` so the two can never disagree. + return Object.freeze({ actions: frozenActions, ok: errors.length === 0, errors }); +} + +// --------------------------------------------------------------------------- +// Agent Skills — validation +// --------------------------------------------------------------------------- + +/** + * The skill key grammar. No `m` flag, so `$` cannot match before a trailing + * newline and let `'pdf-extraction\n'` through as a directory name. + */ +const SKILL_KEY_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +/** + * Longest permitted skill key. `writeSkills` applies a tighter bound, since most + * filesystems cap a path component below 256 bytes. + */ +export const SKILL_KEY_MAX_LENGTH = 256; + +/** Whether `key` is a valid skill key: `^[a-z0-9][a-z0-9-]*$`, at most 256 characters. */ +export function isValidSkillKey(key: unknown): key is string { + return typeof key === 'string' && key.length <= SKILL_KEY_MAX_LENGTH && SKILL_KEY_PATTERN.test(key); +} + +/** Whether `version` is a valid skill version: an integer >= 1 (not `NaN`, `Infinity`, or a boolean). */ +export function isValidSkillVersion(version: unknown): version is number { + return typeof version === 'number' && Number.isInteger(version) && version >= 1; +} + function parseTool(raw: unknown, key: string): string | null { if (!isObject(raw)) return `tools.${key} must be an object`; if (typeof raw.name !== 'string') return `tools.${key}.name must be a string`; @@ -211,6 +469,10 @@ export function parseAiConfig(raw: unknown): ParseResult { return { success: false, error: { message: 'outputFormat must be an object (JSON Schema)' } }; } + // `skills` is passed through unvalidated. Agent Skills is experimental, so a + // malformed field must not fail a core config call (TESTING.md §0.3); + // `skillRefs` rejects it where the references are used. + return { success: true, data: raw as AiConfigRep }; } diff --git a/packages/client/tsup.config.ts b/packages/client/tsup.config.ts index 58df271d..bec42f7a 100644 --- a/packages/client/tsup.config.ts +++ b/packages/client/tsup.config.ts @@ -1 +1,15 @@ -export { default } from '../../tsup.config.base'; +import { defineConfig } from 'tsup'; +import base from '../../tsup.config.base'; + +// Two entry points: the package root and `@launchdarkly/ai-server/experimental`. +// +// Each entry is bundled on its own (no code splitting), so modules both reach — +// `types.ts` and `shutdown-hooks.ts` — are copied into each. The Skills modules +// are reached only from the experimental entry. The copies are safe because the +// shared state lives on `globalThis` symbol slots, not in module variables. Splitting is off because tsup's CJS splitting rewrites the dynamic +// `import()` of the optional peers into `require()`, which bundlers then try to +// resolve statically (AIC-3370; asserted by integration-tests/commonjs-webpack). +export default defineConfig({ + ...base, + entry: { index: 'src/index.ts', experimental: 'src/experimental.ts' }, +}); diff --git a/yarn.lock b/yarn.lock index 429df530..f580631f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -860,6 +860,49 @@ https-proxy-agent "^7.0.6" launchdarkly-eventsource "2.2.0" +"@microsoft/api-extractor-model@7.33.15": + version "7.33.15" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor-model/-/api-extractor-model-7.33.15.tgz#c27a9c4f9ef495618938beb3e4d390d9221b6bec" + integrity sha512-UpCnItIJwu03iNTWtJVKv8Qbf0MXN7WLQZUail9cpAQmnZiIX36YPRRoRxz0pocwytzUpmJhstsLkyW5tsaHBA== + dependencies: + "@microsoft/tsdoc" "~0.17.0" + "@microsoft/tsdoc-config" "~0.18.2" + "@rushstack/node-core-library" "5.25.1" + +"@microsoft/api-extractor@^7.59.4": + version "7.59.4" + resolved "https://registry.yarnpkg.com/@microsoft/api-extractor/-/api-extractor-7.59.4.tgz#29c404a852509f4bd2d77eaac9e0c23fc208d6e7" + integrity sha512-XqseDKIw736qlhQwS9SOpbY4veOiP2Nc7YiWmgXXBmeA3kY9qGXw+Tu7DFpU9FvXywE1z8SvLJKUVxFpcX0kig== + dependencies: + "@microsoft/api-extractor-model" "7.33.15" + "@microsoft/tsdoc" "~0.17.0" + "@microsoft/tsdoc-config" "~0.18.2" + "@rushstack/node-core-library" "5.25.1" + "@rushstack/rig-package" "0.7.4" + "@rushstack/terminal" "0.24.7" + "@rushstack/ts-command-line" "5.3.17" + diff "~8.0.2" + minimatch "10.2.3" + resolve "~1.22.1" + semver "~7.7.4" + source-map "~0.6.1" + typescript "5.9.3" + +"@microsoft/tsdoc-config@~0.18.2": + version "0.18.3" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc-config/-/tsdoc-config-0.18.3.tgz#9afdae403bd339a7a20c88b8569826db1aca8b1a" + integrity sha512-0I0rGXo1eJziw3EEErkDS9p/0RzBcyOiV+zbSDqOZTeURmhtut+WIvpih0LM5g+ZpZ9SyFxDfkhAcl/r8e2zGw== + dependencies: + "@microsoft/tsdoc" "0.17.1" + ajv "~8.18.0" + jju "~1.4.0" + resolve "~1.22.2" + +"@microsoft/tsdoc@0.17.1", "@microsoft/tsdoc@~0.17.0": + version "0.17.1" + resolved "https://registry.yarnpkg.com/@microsoft/tsdoc/-/tsdoc-0.17.1.tgz#b8bb5062280827c4a54443297701a3e70dc1018d" + integrity sha512-VHyYyyoH63jFoO3shpw9Zfijk5v/9p/VrClW35z0/q6ooK7sSmxpzOkS4iZPxs3vO4XtBW4H38HRAKkAMIK7DA== + "@modelcontextprotocol/sdk@^1.26.0": version "1.29.0" resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz#79786d8b525e269de850ac82b1f1f757f3915f44" @@ -1253,6 +1296,52 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz#f8e11d8ac827db7695d50f1def4c00a7f3cd1510" integrity sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ== +"@rushstack/node-core-library@5.25.1": + version "5.25.1" + resolved "https://registry.yarnpkg.com/@rushstack/node-core-library/-/node-core-library-5.25.1.tgz#a09d50a1846406ae77cffb681f4fecef04271f6d" + integrity sha512-ozoRDwIx3jStOYpx86rFyXH4SaUR+DVT6HubQ59avLplJkWXSm5InztYH7RrOzzq9PJXTdq9/+lKX06SfkLVsA== + dependencies: + ajv "~8.20.0" + ajv-draft-04 "~1.0.0" + ajv-formats "~3.0.1" + fs-extra "~11.3.0" + import-lazy "~4.0.0" + jju "~1.4.0" + resolve "~1.22.1" + semver "~7.7.4" + +"@rushstack/problem-matcher@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz#e9f6cac2dd6a882d60e76a8d4462b7d24b4f17e5" + integrity sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog== + +"@rushstack/rig-package@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@rushstack/rig-package/-/rig-package-0.7.4.tgz#6b1e6f760fcf772feb5a03a99d6f914497e0ae89" + integrity sha512-SHZdzc9b2ptUb5uvRWALtAceD/QKZ4UAnZjcaKviTMPmHBnW14gs7XDocn6sDv2EqgnxxFPFkpV7/I3cUijCbw== + dependencies: + jju "~1.4.0" + resolve "~1.22.1" + +"@rushstack/terminal@0.24.7": + version "0.24.7" + resolved "https://registry.yarnpkg.com/@rushstack/terminal/-/terminal-0.24.7.tgz#e3295321268e4342fd6439dd7a47bb36983137f4" + integrity sha512-o8J9nHOIwfYb0s0eG2WYhx7ed3YIvBXQHVFxYdXCmVdBOkTkdArJcgVzRS8ewUepmlic4nGgJPjKIVaNKSqEnA== + dependencies: + "@rushstack/node-core-library" "5.25.1" + "@rushstack/problem-matcher" "0.2.1" + supports-color "~8.1.1" + +"@rushstack/ts-command-line@5.3.17": + version "5.3.17" + resolved "https://registry.yarnpkg.com/@rushstack/ts-command-line/-/ts-command-line-5.3.17.tgz#2d898f02684ed2a1531264424c47307d2f9c573c" + integrity sha512-pIwhWawZXAT0nTXwvcE/InQ7uaVAgSeIzILMb2mBrI01Hz1yEvxrhV2+DE2Fzux8aZY9OJTdLsI+sk9kZDuAJw== + dependencies: + "@rushstack/terminal" "0.24.7" + "@types/argparse" "1.0.38" + argparse "~1.0.9" + string-argv "~0.3.1" + "@smithy/core@^3.33.2", "@smithy/core@^3.33.3": version "3.34.1" resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.34.1.tgz#2fb8133e9f3e99c3f88c8136da2a46cfee4e4fe4" @@ -1321,6 +1410,11 @@ dependencies: tslib "^2.4.0" +"@types/argparse@1.0.38": + version "1.0.38" + resolved "https://registry.yarnpkg.com/@types/argparse/-/argparse-1.0.38.tgz#a81fd8606d481f873a3800c6ebae4f1d768a56a9" + integrity sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA== + "@types/chai@^5.2.2": version "5.2.3" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" @@ -1462,14 +1556,19 @@ ai@^7.0.108: "@ai-sdk/provider" "4.0.17" "@ai-sdk/provider-utils" "5.0.45" -ajv-formats@^3.0.1: +ajv-draft-04@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" + integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== + +ajv-formats@^3.0.1, ajv-formats@~3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== dependencies: ajv "^8.0.0" -ajv@^8.0.0, ajv@^8.17.1: +ajv@^8.0.0, ajv@^8.17.1, ajv@~8.20.0: version "8.20.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9" integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA== @@ -1479,16 +1578,38 @@ ajv@^8.0.0, ajv@^8.17.1: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +ajv@~8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + any-promise@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f" integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== +argparse@~1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -1514,6 +1635,13 @@ bowser@^2.11.0: resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== +brace-expansion@^5.0.2: + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== + dependencies: + balanced-match "^4.0.2" + bundle-require@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/bundle-require/-/bundle-require-5.1.0.tgz#8db66f41950da3d77af1ef3322f4c3e04009faee" @@ -1638,6 +1766,11 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== +diff@~8.0.2: + version "8.0.4" + resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.4.tgz#4f5baf3188b9b2431117b962eb20ba330fadf696" + integrity sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw== + dotenv@^16.5.0: version "16.6.1" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.6.1.tgz#773f0e69527a8315c7285d5ee73c4459d20a8020" @@ -1889,6 +2022,15 @@ fresh@^2.0.0: resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== +fs-extra@~11.3.0: + version "11.3.6" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.6.tgz#f7cb80e9df550cd1db6f537fa5cdd568d3e70d10" + integrity sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -1928,12 +2070,22 @@ gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + has-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -hasown@^2.0.2: +hasown@^2.0.2, hasown@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== @@ -1971,6 +2123,11 @@ iconv-lite@^0.7.2, iconv-lite@~0.7.0: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" +import-lazy@~4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-4.0.0.tgz#e8eb627483a0a43da3c03f3e35548be5cb0cc153" + integrity sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw== + inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" @@ -1986,6 +2143,13 @@ ipaddr.js@1.9.1: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +is-core-module@^2.16.1: + version "2.17.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.17.0.tgz#75c1c76236c9f7e38ddbfff292e127dc13b21027" + integrity sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA== + dependencies: + hasown "^2.0.4" + is-network-error@^1.1.0: version "1.3.2" resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.3.2.tgz#9460bc30f8419a4bca77114f4de88a3ee5e0c519" @@ -2001,6 +2165,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== +jju@~1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jju/-/jju-1.4.0.tgz#a3abe2718af241a2b2904f84a625970f389ae32a" + integrity sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA== + jose@^6.1.3: version "6.2.3" resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.3.tgz#0975197ad973251221c658a3cddc4b951a250c2d" @@ -2041,6 +2210,15 @@ json-schema@^0.4.0: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== +jsonfile@^6.0.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.1.tgz#b6e31717f22cc37330b081ce0051ed5de53af2f6" + integrity sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + langchain@^1.0.0: version "1.4.5" resolved "https://registry.yarnpkg.com/langchain/-/langchain-1.4.5.tgz#b6a88927bd46631bd2518087a0411a2110c3d63d" @@ -2259,6 +2437,13 @@ mime-types@^3.0.0, mime-types@^3.0.2: dependencies: mime-db "^1.54.0" +minimatch@10.2.3: + version "10.2.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.3.tgz#c0ef582f21071b0123a5bbf275252ebda921fbf6" + integrity sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg== + dependencies: + brace-expansion "^5.0.2" + mlly@^1.7.4: version "1.8.2" resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb" @@ -2382,6 +2567,11 @@ path-key@^3.1.0: resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + path-to-regexp@^8.0.0: version "8.4.2" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz#795c420c4f7ca45c5b887366f622ee0c9852cccd" @@ -2482,6 +2672,16 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve@~1.22.1, resolve@~1.22.2: + version "1.22.12" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.12.tgz#f5b2a680897c69c238a13cd16b15671f8b73549f" + integrity sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA== + dependencies: + es-errors "^1.3.0" + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + rolldown@1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/rolldown/-/rolldown-1.0.3.tgz#db88a3008fb0e28230a00423727ce75ba32121ac" @@ -2564,6 +2764,11 @@ semver@7.5.4: dependencies: lru-cache "^6.0.0" +semver@~7.7.4: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + send@^1.1.0, send@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" @@ -2717,6 +2922,16 @@ source-map@^0.7.6: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + stackback@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" @@ -2740,6 +2955,11 @@ std-env@^4.0.0-rc.1: resolved "https://registry.yarnpkg.com/std-env/-/std-env-4.1.0.tgz#45899abc590d86d682e87f0acd1033a75084cd3f" integrity sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ== +string-argv@~0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" + integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== + sucrase@^3.35.0: version "3.35.1" resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1" @@ -2753,6 +2973,18 @@ sucrase@^3.35.0: tinyglobby "^0.2.11" ts-interface-checker "^0.1.9" +supports-color@~8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -2861,7 +3093,7 @@ type-is@^2.0.1, type-is@^2.1.0: media-typer "^1.1.0" mime-types "^3.0.0" -typescript@^5.8.0: +typescript@5.9.3, typescript@^5.8.0: version "5.9.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -2891,6 +3123,11 @@ undici@^7.29.0: resolved "https://registry.yarnpkg.com/undici/-/undici-7.29.1.tgz#7741c6fc8b3e1a48e30323833642bfbe841443ad" integrity sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q== +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"