Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
50721ca
docs(client): document Agent Skills
XieX Aug 25, 2026
c854554
docs(client): Agent Skills — close out the security review's SDK-side…
XieX Sep 4, 2026
6e2ea64
fix(client): a broken store answer is not an absent skill
XieX Sep 11, 2026
67cda6f
test(client): a broken store answer never deletes managed files
XieX Sep 11, 2026
6279f4a
test(client): Agent Skills — the root-swap races the pinned handle do…
pkaeding Sep 4, 2026
4c40603
test(client): let the root-swap races fail outright
pkaeding Sep 4, 2026
0479a61
fix(client): pin the skills root through /proc/self/fd on Linux
XieX Sep 4, 2026
19acd23
docs(client): sweep review-closeout comments
XieX Sep 16, 2026
6197fe3
feat(client): Agent Skills — the FDv2 delivery transport
XieX Sep 4, 2026
7b560b8
fix(client): Agent Skills — read the skill's version off the wire key
XieX Sep 11, 2026
9407ddc
feat(client): Agent Skills — name the payload a transfer completed
XieX Sep 11, 2026
3aa45a8
fix(client): Agent Skills — keep FDv2 delivery alive through the fail…
XieX Sep 11, 2026
d4430b9
feat(client): Agent Skills — removeListener, and a no-store message t…
XieX Sep 11, 2026
ede868a
docs(client): Agent Skills — describe the FDv2 wire contract as it ships
XieX Sep 11, 2026
4a5de2b
fix(client): four delivery defects from the Bugbot review
XieX Sep 15, 2026
44ef4e2
docs(client): Agent Skills — sweep delivery-transport comments for a …
XieX Sep 15, 2026
b73192e
docs(client): Agent Skills — finish the public-SDK comment sweep
XieX Sep 15, 2026
0a0e847
fix(client): Agent Skills — do not expire an unchanging environment
XieX Sep 15, 2026
a92ee61
fix(client): Agent Skills — resolve waitForSkills false when delivery…
XieX Sep 15, 2026
fba8cb8
fix(client): Agent Skills — clear the SSE data buffer on every block
XieX Sep 15, 2026
c4e9d0a
fix(client): Agent Skills — bound the retry loop a goodbye cannot escape
XieX Sep 15, 2026
65dbcae
fix(client): Agent Skills — report revocations and hashless payloads …
XieX Sep 15, 2026
3ebc7ac
fix(client): Agent Skills — tighten the store's listener and wait lif…
XieX Sep 15, 2026
7106c4d
fix(client): Agent Skills — stream from stream.launchdarkly.com by de…
XieX Sep 15, 2026
f13fc19
docs(client): Agent Skills — bring the transport contract notes up to…
XieX Sep 15, 2026
47cdc18
docs(client): sweep transport comments and correct hashlessObjects
XieX Sep 16, 2026
937131d
fix(client)!: Agent Skills — three contract fixes and the tests that …
XieX Sep 16, 2026
8c5dba4
fix(client)!: Agent Skills — the multi-version store model
XieX Sep 16, 2026
8807c40
fix(client): stop reporting a withholding for a key that resolved
XieX Sep 18, 2026
432d75c
fix(client)!: Agent Skills — narrow the filesystem destructive and Do…
XieX Sep 16, 2026
f74c412
fix(client)!: Agent Skills — test integrity, and the two guards the t…
XieX Sep 16, 2026
49db83b
docs(client): regenerate the export block and correct the platform note
XieX Sep 17, 2026
7bfa88e
fix(client)!: Agent Skills — close the readiness and attribution prun…
XieX Sep 30, 2026
43ed093
test(client): pin the key-mismatch detection surface (failing)
XieX Sep 17, 2026
75df51f
fix(client): record the key mismatch on the log surface
XieX Sep 17, 2026
191fcdf
fix(client): Agent Skills — align the FDv2 protocol reader with the spec
XieX Sep 30, 2026
288e302
fix(client): keep the stale-selector repair out of the retry budget
XieX Sep 17, 2026
860b759
fix(client): pair the poll etag with the basis it was issued against
XieX Sep 18, 2026
bd5e4da
fix(client): Agent Skills — bound the SSE decoder per event
XieX Sep 30, 2026
37fccfd
fix(client): bound the transport per response, not per read
XieX Sep 18, 2026
ae858c9
fix(client): refuse cleartext base URIs and redirects in FDv2SkillStore
XieX Sep 22, 2026
6baa20b
fix(client): Agent Skills — watcher, safe-fs and BYOC robustness
XieX Sep 30, 2026
3b8dcd0
feat(client): declare ?kinds=agent-skill, and treat a 422 as "no skil…
XieX Sep 24, 2026
ec7fc2f
docs(client): say the 422's cause once, on the type that names it
XieX Sep 24, 2026
790ef3c
test(client): stop the hashless-reconcile case racing its own second …
XieX Sep 24, 2026
df337a8
fix(client): make HTTP 422 a fatal transport failure
XieX Sep 29, 2026
3eea7e0
Update skills-fdv2.ts
XieX Sep 29, 2026
ea02aec
Update agents.md
XieX Sep 29, 2026
cc86953
docs(client): correct what the kinds declaration made stale
XieX Sep 28, 2026
78659d8
docs(client): finish correcting what the kinds declaration made stale
XieX Sep 29, 2026
21c67e0
Update skills-fdv2.test.ts
XieX Sep 29, 2026
e707d0d
fix(client): record the version mismatch on the log surface
XieX Sep 28, 2026
068143f
docs(client): stop giving a shared connection as the reason for the skip
XieX Sep 28, 2026
91942c5
fix(client): shape-check served_version, matching the Python SDK
XieX Sep 30, 2026
9928c9d
fix(client): reject a non-finite writeSkills timeout
XieX Sep 30, 2026
9efb571
feat(client): Agent Skills — the FDv2 delivery transport (#50)
XieX Sep 30, 2026
6d5dbaa
Merge branch 'xie/agent-skills-feature-ac9ac7' into xie/skills-08-rev…
XieX Sep 30, 2026
b1d2a36
fix(client): Agent Skills — contract fixes, the multi-version store, …
XieX Oct 1, 2026
9151d4a
feat(client): declare ?kinds=agent-skill, and make a 422 a fatal tran…
XieX Oct 1, 2026
a9b9c01
fix(client): Agent Skills — watcher, safe-fs and BYOC robustness (#98)
XieX Oct 1, 2026
439f637
fix(client): Agent Skills — bound the SSE decoder, refuse cleartext U…
XieX Oct 1, 2026
b99e564
fix(client): Agent Skills — align the FDv2 protocol reader with the s…
XieX Oct 1, 2026
82d8dd7
fix(client)!: Agent Skills — close the readiness and attribution prun…
XieX Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 157 additions & 17 deletions packages/client/README.md

Large diffs are not rendered by default.

229 changes: 197 additions & 32 deletions packages/client/agents.md

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions packages/client/src/__tests__/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,23 @@ describe('lifecycle', () => {
expect(mockTracerProviderRegister).toHaveBeenCalled();
});

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 on the
// floor while only `skillStore` is read off it.
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('is idempotent — calls init only once when called twice', async () => {
const mockClient = makeMockClient();
mockLdInit.mockReturnValue(mockClient);
Expand Down
191 changes: 171 additions & 20 deletions packages/client/src/__tests__/safe-fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,15 @@ vi.mock('node:fs/promises', async () => {
};
});

const { atomicWrite, atomicWriteIn, openDirectoryNoFollow, openOrCreateDirectory, unlinkNoFollow } = await import(
'../safe-fs.js'
);
const {
atomicWrite,
directoryAddress,
isDescriptorAddressed,
openDirectoryNoFollow,
openOrCreateDirectory,
SUPPORTS_PROC_FD,
unlinkNoFollow,
} = await import('../safe-fs.js');

let scratch: string;

Expand Down Expand Up @@ -158,26 +164,28 @@ describe('atomicWrite', () => {
}
expect(await readdir(dir)).toEqual(['SKILL.md']);
});

it('atomicWriteIn 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(atomicWriteIn(link, 'SKILL.md', Buffer.from('body\n', 'utf-8'))).rejects.toThrow();
expect(await readdir(outside)).toEqual([]);
});
});

/**
* On Node the identity re-check is the *whole* defense against a directory swapped
* between validation and the destructive call: there is no `*at()` family, so the
* rename and the unlink resolve their directory by path.
*
* The swap is staged directly: pin the handle, replace the directory, then invoke
* the primitive. Both primitives re-check independently, so both halves are
* required.
* Off `SUPPORTS_PROC_FD` it 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-race cases
* in `skills-fs.test.ts` cannot reach it — they fire the swap from the
* rename/unlink hook, which by construction runs *after* the check. Verified by
* mutation: with the check neutered, the entire suite stays green.
*
* So the swap is staged here instead: pin the handle, then replace the directory,
* then invoke the primitive. Both primitives re-check independently, so both
* halves are required.
*
* These call the primitives with a plain path, which is what keeps them
* meaningful on Linux too: the check is skipped only for a directory addressed
* through `directoryAddress`, so passing a path exercises the floor on every
* platform rather than testing nothing wherever the fast path exists.
*/
describe('pinned-directory identity re-check', () => {
/** Moves `dir` aside and leaves a symlink to `outside` in its place. */
Expand All @@ -199,11 +207,11 @@ describe('pinned-directory identity re-check', () => {
await swapForSymlink(dir, outside);

await expect(atomicWrite(dir, 'SKILL.md', Buffer.from('body\n', 'utf-8'), handle)).rejects.toThrow(/replaced/i);
// The temp file *is* created through the swapped link before the check fires
// (only the final component gets O_NOFOLLOW), so this proves the rename is
// refused and the temp cleaned up — not that the outside directory was never
// touched at all.
// 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();
}
Expand Down Expand Up @@ -237,6 +245,149 @@ describe('pinned-directory identity re-check', () => {
});
});

/**
* The Linux fast path's own positive control.
*
* The block above tests the *floor* — deliberately, by passing a plain path, so it
* exercises the identity re-check on every platform. That leaves the fast path
* itself unproven by anything: on Linux the defense lives in how the call is
* *addressed* rather than in a check a test can observe failing, and the §3.23.2
* swap races in `skills-fs.test.ts` and `skills-fs-root-swap.test.ts` go through
* the materialization layer, where the shared path check and the primitive's own
* `O_NOFOLLOW` both stand in the way — so they pass when either layer alone works
* and prove the pair rather than either member.
*
* So assert the property directly and at this layer: nothing else establishes that
* `directoryAddress()` resolves from the inode the handle is pinned to rather than
* from whatever the directory's *name* resolves to when the call is made. This is
* the positive mirror of the floor tests — there the contract is a refusal, here it
* is that 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');
Expand Down
Loading
Loading