Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion sdk-typescript/packages/kernel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,13 @@
"node": ">=18.0.0"
},
"dependencies": {
"@onkernel/sdk": "^0.78.0"
"@noble/ed25519": "^3.1.0",
"@onkernel/sdk": "^0.78.0",
"opossum": "^10.0.0"
Comment on lines +69 to +71

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd -HI -a '^(package\.json|\.nvmrc|\.node-version|\.tool-versions)$' . \
  -x sh -c 'echo "== $1"; rg -n "\"engines\"|\"node\"|node-version" "$1" || true' sh {}

rg -n -g '*.{yml,yaml,json}' 'node-version|setup-node|NODE_VERSION|engines' . || true

Repository: wave-av/sdks

Length of output: 20928


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== package.json (root and kernel)"
sed -n '1,120p' sdk-typescript/package.json | cat -n
echo
sed -n '1,120p' sdk-typescript/packages/kernel/package.json | cat -n

echo
echo "== lockfile entries for `@noble/ed25519` and opossum"
python3 - <<'PY'
from pathlib import Path
p = Path('sdk-typescript/pnpm-lock.yaml')
text = p.read_text()
for needle in ['`@noble/ed25519`', 'opossum']:
    print(f"\n## {needle}")
    idx = 0
    while True:
        idx = text.find(needle, idx)
        if idx == -1:
            break
        start = text.rfind('\n', 0, idx)
        end = text.find('\n', idx)
        print(text[max(0, start-250):min(len(text), end+400)])
        print("\n---")
        idx += len(needle)
PY

Repository: wave-av/sdks

Length of output: 9458


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for needle in '`@noble/ed25519`' 'opossum'; do
  echo "===== $needle ====="
  rg -n -C 4 --fixed-strings "$needle" sdk-typescript/pnpm-lock.yaml
  echo
done

Repository: wave-av/sdks

Length of output: 2788


🌐 Web query:

npm @noble/ed25519 3.1.0 engines node opossum 10.0.0 engines node

💡 Result:

The Node.js engine requirements for the specified package versions are as follows: - @noble/ed25519 version 3.1.0 requires Node.js v20.19 or later [1][2][3]. - opossum version 10.0.0 requires Node.js v22 or later (Node.js >= 22) [4][5]. This version introduced a breaking change by removing support for Node.js 20 [6][7].

Citations:


🌐 Web query:

site:npmjs.com opossum 10.0.0 engines node

💡 Result:

The opossum npm package, which serves as a circuit breaker for Node.js, specifies its Node.js engine requirement as ">=22" in its metadata [1]. This means that the package is intended for use in environments running Node.js version 22 or newer [1]. Users should ensure their Node.js environment meets this requirement to avoid potential compatibility issues during installation or execution [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1426,1440p' sdk-typescript/pnpm-lock.yaml
echo '---'
sed -n '2210,2225p' sdk-typescript/pnpm-lock.yaml
echo '---'
sed -n '2374,2386p' sdk-typescript/pnpm-lock.yaml

Repository: wave-av/sdks

Length of output: 1943


Raise the kernel Node engine floor. sdk-typescript/packages/kernel/package.json:65-71 still says node: >=18.0.0, but @noble/ed25519@3.1.0 needs Node 20.19+ and opossum@10.0.0 needs Node 22+, so this package no longer matches its declared runtime support. Either pin older compatible versions or bump the documented engines/CI baseline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdk-typescript/packages/kernel/package.json` around lines 69 - 71, Update the
kernel package’s engines declaration and corresponding CI/runtime baseline to
require Node 22 or newer, matching the requirements of `@noble/ed25519` and
opossum; keep the dependency versions unchanged.

},
"devDependencies": {
"@types/node": "^25.0.3",
"@types/opossum": "^8.1.9",
"tsup": "^8.0.0",
"typescript": "^5.9.0",
"vitest": "^4.1.4"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import {
createCircuitBreaker,
OpossumCircuitBreaker,
} from '../circuit-breaker.js';
import { CircuitOpenError, KernelErrorCode } from '../errors.js';
import type { KernelBreadcrumb } from '../resilience.js';

describe('createCircuitBreaker', () => {
it('returns an OpossumCircuitBreaker', () => {
expect(createCircuitBreaker()).toBeInstanceOf(OpossumCircuitBreaker);
});

it('passes through the action result while closed', async () => {
const breaker = createCircuitBreaker();
await expect(breaker.fire(async () => 42)).resolves.toBe(42);
expect(breaker.opened).toBe(false);
});

it('propagates action errors while closed', async () => {
// High volumeThreshold so a single failure does not trip the breaker.
const breaker = createCircuitBreaker({ volumeThreshold: 100 });
await expect(
breaker.fire(async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
});

it('opens after failures and rejects fast with CircuitOpenError', async () => {
const breadcrumbs: KernelBreadcrumb[] = [];
const breaker = createCircuitBreaker({
volumeThreshold: 1,
errorThresholdPercentage: 1,
resetTimeout: 10_000,
captureBreadcrumb: (b) => breadcrumbs.push(b),
});

await expect(
breaker.fire(async () => {
throw new Error('fail');
}),
).rejects.toThrow('fail');

expect(breaker.opened).toBe(true);

const err = await breaker.fire(async () => 1).catch((e: unknown) => e);
expect(err).toBeInstanceOf(CircuitOpenError);
expect((err as CircuitOpenError).code).toBe(KernelErrorCode.CIRCUIT_OPEN);

const open = breadcrumbs.find((b) => b.data?.state === 'open');
expect(open).toBeDefined();
expect(open?.category).toBe('kernel');
expect(open?.level).toBe('warning');
});

it('names breadcrumbs and the circuit-open error after serviceName', async () => {
const breadcrumbs: KernelBreadcrumb[] = [];
const breaker = createCircuitBreaker({
serviceName: 'kernel-edge',
volumeThreshold: 1,
errorThresholdPercentage: 1,
captureBreadcrumb: (b) => breadcrumbs.push(b),
});
await breaker
.fire(async () => {
throw new Error('x');
})
.catch(() => undefined);
const err = await breaker.fire(async () => 1).catch((e: unknown) => e);
expect((err as CircuitOpenError).context).toEqual({
serviceName: 'kernel-edge',
});
expect(breadcrumbs[0]?.category).toBe('kernel-edge');
});
});
42 changes: 42 additions & 0 deletions sdk-typescript/packages/kernel/src/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import {
isKernelApiError,
} from '../errors.js';
import { withTelemetry, WAVE_TELEMETRY_DEFAULT } from '../telemetry.js';
import { createCircuitBreaker } from '../circuit-breaker.js';
import { createWebBotAuthSigner } from '../web-bot-auth.js';

const TEST_KEY_HEX = '00'.repeat(31) + '07';

describe('WaveKernel', () => {
it('constructs with an explicit apiKey without throwing (no network)', () => {
Expand Down Expand Up @@ -58,6 +62,44 @@ describe('WaveKernel', () => {
});
});

describe('WaveKernel resilience wiring', () => {
it('run() executes directly when no breaker is configured (inert)', async () => {
const kernel = new WaveKernel({ apiKey: 'x' });
await expect(kernel.run(async () => 'ok')).resolves.toBe('ok');
});

it('run() routes through the breaker and reports errors via captureError', async () => {
const errors: unknown[] = [];
const breaker = createCircuitBreaker({ volumeThreshold: 100 });
const kernel = new WaveKernel({
apiKey: 'x',
resilience: { breaker, captureError: (e) => errors.push(e) },
});
await expect(
kernel.run(async () => {
throw new Error('nope');
}),
).rejects.toThrow('nope');
expect(errors).toHaveLength(1);
});

it('constructs with a signer wired in without throwing (no network)', () => {
const signer = createWebBotAuthSigner({
privateKey: TEST_KEY_HEX,
keyId: 'k1',
});
expect(
() => new WaveKernel({ apiKey: 'x', resilience: { signer } }),
).not.toThrow();
});

it('exposes the resilience hooks it was constructed with', () => {
const breaker = createCircuitBreaker();
const kernel = new WaveKernel({ apiKey: 'x', resilience: { breaker } });
expect(kernel.resilience?.breaker).toBe(breaker);
});
});

describe('withTelemetry', () => {
it('enables console/network/page and leaves screenshot undefined', () => {
const params = withTelemetry({});
Expand Down
165 changes: 165 additions & 0 deletions sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { describe, it, expect } from 'vitest';
import * as ed25519 from '@noble/ed25519';
import {
createWebBotAuthSigner,
WebBotAuthSigner,
} from '../web-bot-auth.js';

const KEY_HEX = '00'.repeat(31) + '07';
const FIXED_NOW = () => 1_700_000_000_000;

/**
* Independently rebuild the RFC-9421 signature base a spec-compliant verifier
* would construct — driven ONLY by the produced `Signature-Input` header and
* the request, NOT by the signer's own base builder. Parses the covered
* component list (in header order), recomputes each derived component value
* from the request per RFC-9421, and appends the verbatim `@signature-params`.
*/
function reconstructBase(
req: { method: string; url: string; signatureAgent?: string },
signatureInput: string,
label = 'sig1',
): string {
const params = signatureInput.replace(new RegExp(`^${label}=`), '');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Semgrep identified an issue in your code:
RegExp() called with a label function argument, this might allow an attacker to cause a Regular Expression Denial-of-Service (ReDoS) within your application as RegExP blocks the main thread. For this reason, it is recommended to use hardcoded regexes instead. If your regex is run on user-controlled input, consider performing input validation or use a regex checking/sanitization library such as https://www.npmjs.com/package/recheck to verify that the regex does not appear vulnerable to ReDoS.

Dataflow graph
flowchart LR
    classDef invis fill:white, stroke: none
    classDef default fill:#e7f5ff, color:#1c7fd6, stroke: none

    subgraph File0["<b>sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts</b>"]
        direction LR
        %% Source

        subgraph Source
            direction LR

            v0["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L21 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 21] label</a>"]
        end
        %% Intermediate

        subgraph Traces0[Traces]
            direction TB

            v2["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L21 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 21] label</a>"]

            v3["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L23 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 23] `</a>"]
        end
            v2 --> v3
        %% Sink

        subgraph Sink
            direction LR

            v1["<a href=https://github.com/wave-av/sdks/blob/7eab4af663bb1151efc7c9d1f8fbafa8894b86c5/sdk-typescript/packages/kernel/src/__tests__/web-bot-auth.test.ts#L23 target=_blank style='text-decoration:none; color:#1c7fd6'>[Line: 23] new RegExp(`^${label}=`)</a>"]
        end
    end
    %% Class Assignment
    Source:::invis
    Sink:::invis

    Traces0:::invis
    File0:::invis

    %% Connections

    Source --> Traces0
    Traces0 --> Sink


Loading

To resolve this comment:

🔧 No guidance has been designated for this issue. Fix according to your organization's approved methods.

💬 Ignore this finding

Reply with Semgrep commands to ignore this finding.

  • /fp <comment> for false positive
  • /ar <comment> for acceptable risk
  • /other <comment> for all other reasons

Alternatively, triage in Semgrep AppSec Platform to ignore the finding created by detect-non-literal-regexp.

You can view more details about this finding in the Semgrep AppSec Platform.

const inner = params.slice(params.indexOf('(') + 1, params.indexOf(')'));
const ids = inner.length
? inner.split(' ').map((t) => t.replace(/^"|"$/g, ''))
: [];
const url = new URL(req.url);
const method = req.method.toUpperCase();
const valueFor = (id: string): string => {
switch (id) {
case '@method':
return method;
case '@authority':
return url.host;
case '@path':
return url.pathname;
case '@query':
return url.search || '?';
case '@target-uri':
return url.href;
case 'signature-agent':
return req.signatureAgent ?? '';
default:
throw new Error(`test reconstruct: unhandled component "${id}"`);
}
};
const lines = ids.map((id) => `"${id}": ${valueFor(id)}`);
lines.push(`"@signature-params": ${params}`);
return lines.join('\n');
}

async function verifySigned(
req: { method: string; url: string; signatureAgent?: string },
out: Record<string, string>,
pub: Uint8Array,
): Promise<boolean> {
const base = reconstructBase(req, out['Signature-Input']);
const sigB64 = out['Signature'].replace(/^sig1=:/, '').replace(/:$/, '');
const sigBytes = Uint8Array.from(Buffer.from(sigB64, 'base64'));
return ed25519.verifyAsync(sigBytes, new TextEncoder().encode(base), pub);
}

describe('createWebBotAuthSigner', () => {
it('returns a WebBotAuthSigner', () => {
expect(
createWebBotAuthSigner({ privateKey: KEY_HEX, keyId: 'k1' }),
).toBeInstanceOf(WebBotAuthSigner);
});

it('produces RFC-9421 Signature and Signature-Input headers', async () => {
const signer = createWebBotAuthSigner({
privateKey: KEY_HEX,
keyId: 'k1',
now: FIXED_NOW,
});
const out = await signer.sign({
method: 'get',
url: 'https://api.onkernel.com/browsers?x=1',
headers: {},
});
expect(out['Signature']).toMatch(/^sig1=:.+:$/);
expect(out['Signature-Input']).toContain('keyid="k1"');
expect(out['Signature-Input']).toContain('alg="ed25519"');
expect(out['Signature-Input']).toContain('created=1700000000');
expect(out['Signature-Input']).toContain('expires=1700000060');
// Default covered set now carries @query as a distinct component.
expect(out['Signature-Input']).toContain(
'"@authority" "@method" "@path" "@query"',
);
expect(out['Signature-Agent']).toBeUndefined();
});

it('includes and covers Signature-Agent when configured', async () => {
const signer = createWebBotAuthSigner({
privateKey: KEY_HEX,
keyId: 'k1',
signatureAgent: '"https://wave.online"',
});
const out = await signer.sign({
method: 'GET',
url: 'https://api.onkernel.com/',
headers: {},
});
expect(out['Signature-Agent']).toBe('"https://wave.online"');
expect(out['Signature-Input']).toContain('"signature-agent"');
});

it('signs a URL WITH query params so a real verifier reconstructs a byte-identical base', async () => {
const priv = ed25519.etc.hexToBytes(KEY_HEX);
const pub = await ed25519.getPublicKeyAsync(priv);
const signer = createWebBotAuthSigner({
privateKey: priv,
keyId: 'k1',
now: FIXED_NOW,
});
const req = {
method: 'GET',
url: 'https://api.onkernel.com/browsers?foo=bar&baz=1',
};
const out = await signer.sign({ ...req, headers: {} });

// @path must be path-only; @query must carry the full query separately.
const base = reconstructBase(req, out['Signature-Input']);
expect(base).toContain('"@path": /browsers\n');
expect(base).toContain('"@query": ?foo=bar&baz=1');
expect(base).not.toContain('/browsers?foo=bar');

expect(await verifySigned(req, out, pub)).toBe(true);
});

it('signs a URL with NO query: @path is bare path and @query is "?"', async () => {
const priv = ed25519.etc.hexToBytes(KEY_HEX);
const pub = await ed25519.getPublicKeyAsync(priv);
const signer = createWebBotAuthSigner({
privateKey: priv,
keyId: 'k1',
now: FIXED_NOW,
});
const req = { method: 'POST', url: 'https://api.onkernel.com/v1/x' };
const out = await signer.sign({ ...req, headers: {} });

const base = reconstructBase(req, out['Signature-Input']);
expect(base).toContain('"@path": /v1/x\n');
expect(base).toContain('"@query": ?\n');

expect(await verifySigned(req, out, pub)).toBe(true);
});

it('fails open: returns {} and reports via captureError on invalid input', async () => {
const errors: unknown[] = [];
const signer = createWebBotAuthSigner({
privateKey: KEY_HEX,
keyId: 'k1',
captureError: (e) => errors.push(e),
});
const out = await signer.sign({
method: 'GET',
url: 'not-a-valid-url',
headers: {},
});
expect(out).toEqual({});
expect(errors).toHaveLength(1);
});
});
Loading
Loading