@nimbus-dev/sdk 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +34 -0
  3. package/dist/audit-logger.d.ts +6 -0
  4. package/dist/audit-logger.d.ts.map +1 -0
  5. package/dist/audit-logger.js +18 -0
  6. package/dist/audit-logger.js.map +1 -0
  7. package/dist/contract-tests.d.ts +45 -0
  8. package/dist/contract-tests.d.ts.map +1 -0
  9. package/dist/contract-tests.js +191 -0
  10. package/dist/contract-tests.js.map +1 -0
  11. package/dist/crypto/app-store-connect-jwt.d.ts +19 -0
  12. package/dist/crypto/app-store-connect-jwt.d.ts.map +1 -0
  13. package/dist/crypto/app-store-connect-jwt.js +30 -0
  14. package/dist/crypto/app-store-connect-jwt.js.map +1 -0
  15. package/dist/crypto/canonical-json.d.ts +36 -0
  16. package/dist/crypto/canonical-json.d.ts.map +1 -0
  17. package/dist/crypto/canonical-json.js +75 -0
  18. package/dist/crypto/canonical-json.js.map +1 -0
  19. package/dist/crypto/jwt.d.ts +30 -0
  20. package/dist/crypto/jwt.d.ts.map +1 -0
  21. package/dist/crypto/jwt.js +30 -0
  22. package/dist/crypto/jwt.js.map +1 -0
  23. package/dist/crypto/service-account-token.d.ts +36 -0
  24. package/dist/crypto/service-account-token.d.ts.map +1 -0
  25. package/dist/crypto/service-account-token.js +96 -0
  26. package/dist/crypto/service-account-token.js.map +1 -0
  27. package/dist/crypto/verify-signature.d.ts +57 -0
  28. package/dist/crypto/verify-signature.d.ts.map +1 -0
  29. package/dist/crypto/verify-signature.js +102 -0
  30. package/dist/crypto/verify-signature.js.map +1 -0
  31. package/dist/distribution-channel.d.ts +34 -0
  32. package/dist/distribution-channel.d.ts.map +1 -0
  33. package/dist/distribution-channel.js +73 -0
  34. package/dist/distribution-channel.js.map +1 -0
  35. package/dist/hitl-request.d.ts +7 -0
  36. package/dist/hitl-request.d.ts.map +1 -0
  37. package/dist/hitl-request.js +15 -0
  38. package/dist/hitl-request.js.map +1 -0
  39. package/dist/index.d.ts +23 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +19 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/ipc/index.d.ts +2 -0
  44. package/dist/ipc/index.d.ts.map +1 -0
  45. package/dist/ipc/index.js +2 -0
  46. package/dist/ipc/index.js.map +1 -0
  47. package/dist/ipc/ndjson-line-reader.d.ts +20 -0
  48. package/dist/ipc/ndjson-line-reader.d.ts.map +1 -0
  49. package/dist/ipc/ndjson-line-reader.js +56 -0
  50. package/dist/ipc/ndjson-line-reader.js.map +1 -0
  51. package/dist/server.d.ts +29 -0
  52. package/dist/server.d.ts.map +1 -0
  53. package/dist/server.js +23 -0
  54. package/dist/server.js.map +1 -0
  55. package/dist/testing/index.d.ts +15 -0
  56. package/dist/testing/index.d.ts.map +1 -0
  57. package/dist/testing/index.js +17 -0
  58. package/dist/testing/index.js.map +1 -0
  59. package/dist/testing/sandbox-contract.d.ts +83 -0
  60. package/dist/testing/sandbox-contract.d.ts.map +1 -0
  61. package/dist/testing/sandbox-contract.js +105 -0
  62. package/dist/testing/sandbox-contract.js.map +1 -0
  63. package/dist/testing/sandbox-probe.d.ts +23 -0
  64. package/dist/testing/sandbox-probe.d.ts.map +1 -0
  65. package/dist/testing/sandbox-probe.js +78 -0
  66. package/dist/testing/sandbox-probe.js.map +1 -0
  67. package/dist/types.d.ts +41 -0
  68. package/dist/types.d.ts.map +1 -0
  69. package/dist/types.js +5 -0
  70. package/dist/types.js.map +1 -0
  71. package/package.json +55 -0
  72. package/src/audit-logger.test.ts +33 -0
  73. package/src/audit-logger.ts +23 -0
  74. package/src/contract-tests.test.ts +203 -0
  75. package/src/contract-tests.ts +220 -0
  76. package/src/crypto/app-store-connect-jwt.test.ts +80 -0
  77. package/src/crypto/app-store-connect-jwt.ts +42 -0
  78. package/src/crypto/canonical-json.test.ts +121 -0
  79. package/src/crypto/canonical-json.ts +73 -0
  80. package/src/crypto/jwt.test.ts +62 -0
  81. package/src/crypto/jwt.ts +45 -0
  82. package/src/crypto/service-account-token.test.ts +128 -0
  83. package/src/crypto/service-account-token.ts +116 -0
  84. package/src/crypto/verify-signature.test.ts +118 -0
  85. package/src/crypto/verify-signature.ts +138 -0
  86. package/src/distribution-channel.test.ts +107 -0
  87. package/src/distribution-channel.ts +105 -0
  88. package/src/hitl-request.ts +22 -0
  89. package/src/index.ts +59 -0
  90. package/src/ipc/index.ts +5 -0
  91. package/src/ipc/ndjson-line-reader.test.ts +64 -0
  92. package/src/ipc/ndjson-line-reader.ts +70 -0
  93. package/src/plugin-api-v1.test.ts +50 -0
  94. package/src/sdk.test.ts +23 -0
  95. package/src/server.test.ts +96 -0
  96. package/src/server.ts +39 -0
  97. package/src/testing/index.ts +18 -0
  98. package/src/testing/sandbox-contract.test.ts +146 -0
  99. package/src/testing/sandbox-contract.ts +155 -0
  100. package/src/testing/sandbox-probe.ts +87 -0
  101. package/src/types.ts +42 -0
@@ -0,0 +1,107 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { channelUpgradeHint, resolveDistributionChannel } from "./distribution-channel.ts";
3
+
4
+ describe("resolveDistributionChannel", () => {
5
+ test("returns null for an ordinary install path with no env marker", () => {
6
+ expect(
7
+ resolveDistributionChannel({ env: {}, execPath: "/home/u/.local/bin/nimbus" }),
8
+ ).toBeNull();
9
+ });
10
+
11
+ test("honors a valid NIMBUS_DISTRIBUTION_CHANNEL env marker", () => {
12
+ expect(
13
+ resolveDistributionChannel({
14
+ env: { NIMBUS_DISTRIBUTION_CHANNEL: "msi" },
15
+ execPath: "/home/u/.local/bin/nimbus",
16
+ }),
17
+ ).toBe("msi");
18
+ });
19
+
20
+ test("ignores an unknown env marker value (fails closed to path detection)", () => {
21
+ expect(
22
+ resolveDistributionChannel({
23
+ env: { NIMBUS_DISTRIBUTION_CHANNEL: "bogus" },
24
+ execPath: "/home/u/.local/bin/nimbus",
25
+ }),
26
+ ).toBeNull();
27
+ });
28
+
29
+ test("detects a macOS Homebrew Cellar path", () => {
30
+ expect(
31
+ resolveDistributionChannel({
32
+ env: {},
33
+ execPath: "/opt/homebrew/Cellar/nimbus/0.1.0/bin/nimbus",
34
+ }),
35
+ ).toBe("homebrew");
36
+ });
37
+
38
+ test("detects a Linuxbrew path", () => {
39
+ expect(
40
+ resolveDistributionChannel({
41
+ env: {},
42
+ execPath: "/home/linuxbrew/.linuxbrew/Cellar/nimbus/0.1.0/bin/nimbus-gateway",
43
+ }),
44
+ ).toBe("homebrew");
45
+ });
46
+
47
+ test("detects a Scoop apps path (Windows-style backslashes)", () => {
48
+ expect(
49
+ resolveDistributionChannel({
50
+ env: {},
51
+ execPath: "C:\\Users\\u\\scoop\\apps\\nimbus\\current\\nimbus.exe",
52
+ }),
53
+ ).toBe("scoop");
54
+ });
55
+
56
+ test("env marker wins over a conflicting path", () => {
57
+ expect(
58
+ resolveDistributionChannel({
59
+ env: { NIMBUS_DISTRIBUTION_CHANNEL: "homebrew" },
60
+ execPath: "C:\\Users\\u\\scoop\\apps\\nimbus\\current\\nimbus.exe",
61
+ }),
62
+ ).toBe("homebrew");
63
+ });
64
+
65
+ test("resolves a Homebrew bin symlink to its Cellar target before matching", () => {
66
+ expect(
67
+ resolveDistributionChannel({
68
+ env: {},
69
+ execPath: "/opt/homebrew/bin/nimbus",
70
+ realpath: (p) =>
71
+ p === "/opt/homebrew/bin/nimbus" ? "/opt/homebrew/Cellar/nimbus/0.1.0/bin/nimbus" : p,
72
+ }),
73
+ ).toBe("homebrew");
74
+ });
75
+
76
+ test("an explicit NIMBUS_DISTRIBUTION_CHANNEL=msi env marker resolves to the msi channel", () => {
77
+ const channel = resolveDistributionChannel({
78
+ env: { NIMBUS_DISTRIBUTION_CHANNEL: "msi" },
79
+ execPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Nimbus\\bin\\nimbus.exe",
80
+ realpath: (p) => p,
81
+ });
82
+ expect(channel).toBe("msi");
83
+ });
84
+
85
+ test("a scripted install.ps1 path (same %LOCALAPPDATA%\\Programs\\Nimbus dir, no env) is NOT msi", () => {
86
+ const channel = resolveDistributionChannel({
87
+ env: {},
88
+ execPath: "C:\\Users\\me\\AppData\\Local\\Programs\\Nimbus\\bin\\nimbus.exe",
89
+ realpath: (p) => p,
90
+ });
91
+ expect(channel).toBeNull();
92
+ });
93
+ });
94
+
95
+ describe("channelUpgradeHint", () => {
96
+ test.each([
97
+ ["homebrew", "brew upgrade nimbus"],
98
+ ["scoop", "scoop update nimbus"],
99
+ ["winget", "winget upgrade NimbusAgent.Nimbus"],
100
+ ["apt", "apt upgrade nimbus"],
101
+ ["yum", "dnf upgrade nimbus"],
102
+ ["msi", ".msi"],
103
+ ["pkg", ".pkg"],
104
+ ] as const)("%s hint mentions the right command", (channel, needle) => {
105
+ expect(channelUpgradeHint(channel)).toContain(needle);
106
+ });
107
+ });
@@ -0,0 +1,105 @@
1
+ import { realpathSync } from "node:fs";
2
+
3
+ /**
4
+ * Channels a Nimbus binary can be distributed through. When Nimbus runs from a
5
+ * package-manager install, the self-updater steps aside so the package manager
6
+ * owns updates.
7
+ *
8
+ * Lives in the SDK so the gateway and CLI share one copy: the `cli` package
9
+ * reaches the gateway over IPC only (no source imports), so a shared pure helper
10
+ * must live in a package both may depend on — the same pattern the manifest
11
+ * signer uses.
12
+ */
13
+ export type DistributionChannel = "homebrew" | "scoop" | "winget" | "apt" | "yum" | "msi" | "pkg";
14
+
15
+ const KNOWN_CHANNELS = new Set<DistributionChannel>([
16
+ "homebrew",
17
+ "scoop",
18
+ "winget",
19
+ "apt",
20
+ "yum",
21
+ "msi",
22
+ "pkg",
23
+ ] satisfies readonly DistributionChannel[]);
24
+
25
+ export interface ResolveChannelOptions {
26
+ /** Defaults to `process.env`. */
27
+ env?: Record<string, string | undefined>;
28
+ /** Defaults to `process.execPath`. */
29
+ execPath?: string;
30
+ /**
31
+ * Resolves symlinks so a package manager's real install path (e.g. Homebrew's
32
+ * Cellar) is inspected rather than the `bin` symlink that launched the process.
33
+ * Injectable for tests; defaults to a safe `realpathSync` that falls back to the
34
+ * input path if resolution fails.
35
+ */
36
+ realpath?: (p: string) => string;
37
+ }
38
+
39
+ function safeRealpath(p: string): string {
40
+ try {
41
+ return realpathSync(p);
42
+ } catch {
43
+ return p;
44
+ }
45
+ }
46
+
47
+ function fromEnv(env: Record<string, string | undefined>): DistributionChannel | null {
48
+ const raw = env["NIMBUS_DISTRIBUTION_CHANNEL"];
49
+ if (raw && KNOWN_CHANNELS.has(raw as DistributionChannel)) {
50
+ return raw as DistributionChannel;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function fromPath(execPath: string, realpath: (p: string) => string): DistributionChannel | null {
56
+ // Resolve symlinks first: package managers expose the binary via a symlink whose
57
+ // own path may not contain the tell-tale Cellar/apps segment.
58
+ const resolved = realpath(execPath);
59
+ const p = resolved.replaceAll("\\", "/").toLowerCase();
60
+ // Homebrew: macOS `/opt/homebrew/Cellar/...` or `/usr/local/Cellar/...`,
61
+ // Linuxbrew `/home/linuxbrew/.linuxbrew/...`.
62
+ if (p.includes("/cellar/") || p.includes("/.linuxbrew/")) {
63
+ return "homebrew";
64
+ }
65
+ // Scoop: `~/scoop/apps/<app>/...`.
66
+ if (p.includes("/scoop/apps/")) {
67
+ return "scoop";
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Resolve the distribution channel this binary was installed through, or `null`
74
+ * for a plain/direct-download install (where the self-updater stays enabled).
75
+ * An explicit `NIMBUS_DISTRIBUTION_CHANNEL` env marker takes precedence over
76
+ * path heuristics; an unknown marker value is ignored.
77
+ */
78
+ export function resolveDistributionChannel(
79
+ opts: ResolveChannelOptions = {},
80
+ ): DistributionChannel | null {
81
+ const env = opts.env ?? process.env;
82
+ const execPath = opts.execPath ?? process.execPath;
83
+ const realpath = opts.realpath ?? safeRealpath;
84
+ return fromEnv(env) ?? fromPath(execPath, realpath);
85
+ }
86
+
87
+ /** Human-facing upgrade hint per channel, used by `nimbus update`. */
88
+ export function channelUpgradeHint(channel: DistributionChannel): string {
89
+ switch (channel) {
90
+ case "homebrew":
91
+ return "Installed via Homebrew — run 'brew upgrade nimbus' to update.";
92
+ case "scoop":
93
+ return "Installed via Scoop — run 'scoop update nimbus' to update.";
94
+ case "winget":
95
+ return "Installed via winget — run 'winget upgrade NimbusAgent.Nimbus' to update.";
96
+ case "apt":
97
+ return "Installed via apt — run 'sudo apt update && sudo apt upgrade nimbus' to update.";
98
+ case "yum":
99
+ return "Installed via dnf/yum — run 'sudo dnf upgrade nimbus' to update.";
100
+ case "msi":
101
+ return "Installed via the Windows installer — download the latest .msi from the releases page.";
102
+ case "pkg":
103
+ return "Installed via the macOS installer — download the latest .pkg from the releases page.";
104
+ }
105
+ }
@@ -0,0 +1,22 @@
1
+ export interface HitlRequest {
2
+ actionId: string;
3
+ summary: string;
4
+ diff?: string;
5
+ }
6
+
7
+ export function isHitlRequest(value: unknown): value is HitlRequest {
8
+ if (typeof value !== "object" || value === null) {
9
+ return false;
10
+ }
11
+ const candidate = value as Record<string, unknown>;
12
+ const actionId = candidate["actionId"];
13
+ const summary = candidate["summary"];
14
+ const diff = candidate["diff"];
15
+ return (
16
+ typeof actionId === "string" &&
17
+ actionId.length > 0 &&
18
+ typeof summary === "string" &&
19
+ summary.length > 0 &&
20
+ (diff === undefined || typeof diff === "string")
21
+ );
22
+ }
package/src/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @nimbus-dev/sdk v1.0.0 — Plugin API v1 (stable baseline)
3
+ * MIT License
4
+ *
5
+ * Typed scaffolding for building Nimbus extensions (MCP connectors).
6
+ * See CHANGELOG.md for the stable surface guarantee.
7
+ */
8
+
9
+ export type { AuditEmit, AuditLogger } from "./audit-logger";
10
+ export { createScopedAuditLogger } from "./audit-logger";
11
+ export {
12
+ assertNoRowDataTools,
13
+ ExtensionContractError,
14
+ ROW_DATA_TOOL_SEGMENTS,
15
+ type RowDataToolCandidate,
16
+ runContractTests,
17
+ } from "./contract-tests";
18
+ export {
19
+ type AppStoreConnectJwtParams,
20
+ signAppStoreConnectJwt,
21
+ } from "./crypto/app-store-connect-jwt";
22
+ export {
23
+ canonicalize,
24
+ canonicalizeManifest,
25
+ ManifestNestedTooDeep,
26
+ NonIntegerNumberInManifest,
27
+ UnsupportedManifestValueType,
28
+ } from "./crypto/canonical-json";
29
+ export { base64UrlJson, type SignJwtOptions, signJwt } from "./crypto/jwt";
30
+ export {
31
+ type FetchLike,
32
+ type GoogleServiceAccount,
33
+ mintGoogleAccessToken,
34
+ parseServiceAccountJson,
35
+ signServiceAccountAssertion,
36
+ } from "./crypto/service-account-token";
37
+ export type { SignatureDisableReason } from "./crypto/verify-signature";
38
+ export {
39
+ decodeBase64,
40
+ encodeBase64,
41
+ errorToHardDisableReason,
42
+ generateEd25519Keypair,
43
+ PublisherKeyMismatch,
44
+ SignatureInvalid,
45
+ SignatureInvalidFormat,
46
+ signManifest,
47
+ verifyManifestSignature,
48
+ } from "./crypto/verify-signature";
49
+ export {
50
+ channelUpgradeHint,
51
+ type DistributionChannel,
52
+ type ResolveChannelOptions,
53
+ resolveDistributionChannel,
54
+ } from "./distribution-channel";
55
+ export type { HitlRequest } from "./hitl-request";
56
+ export { isHitlRequest } from "./hitl-request";
57
+ export { NimbusExtensionServer } from "./server";
58
+ export { MockGateway } from "./testing/index";
59
+ export type { ExtensionManifest, NimbusItem } from "./types";
@@ -0,0 +1,5 @@
1
+ export {
2
+ IPC_MAX_LINE_BYTES,
3
+ NdjsonLineReader,
4
+ type NdjsonLineReaderOptions,
5
+ } from "./ndjson-line-reader.js";
@@ -0,0 +1,64 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { IPC_MAX_LINE_BYTES, NdjsonLineReader } from "./ndjson-line-reader.ts";
4
+
5
+ describe("NdjsonLineReader", () => {
6
+ test("emits non-empty lines and skips blanks", () => {
7
+ const r = new NdjsonLineReader();
8
+ const lines = r.push(new TextEncoder().encode('{"a":1}\n\n{"b":2}\n'));
9
+ expect(lines).toEqual(['{"a":1}', '{"b":2}']);
10
+ });
11
+
12
+ test("throws when a line exceeds IPC_MAX_LINE_BYTES", () => {
13
+ const r = new NdjsonLineReader();
14
+ const huge = `${"x".repeat(IPC_MAX_LINE_BYTES + 1)}\n`;
15
+ expect(() => r.push(new TextEncoder().encode(huge))).toThrow("Message exceeds 1MB line limit");
16
+ });
17
+
18
+ test("buffers a partial line across push() calls", () => {
19
+ const r = new NdjsonLineReader();
20
+ const enc = new TextEncoder();
21
+ expect(r.push(enc.encode('{"a"'))).toEqual([]);
22
+ expect(r.push(enc.encode(":1}\n"))).toEqual(['{"a":1}']);
23
+ });
24
+ test("strips a trailing carriage return", () => {
25
+ const r = new NdjsonLineReader();
26
+ expect(r.push(new TextEncoder().encode('{"a":1}\r\n'))).toEqual(['{"a":1}']);
27
+ });
28
+ test("decodes multi-byte UTF-8 split across chunk boundaries", () => {
29
+ const r = new NdjsonLineReader();
30
+ const full = new TextEncoder().encode('"é"\n');
31
+ const cut = 2;
32
+ expect(r.push(full.slice(0, cut))).toEqual([]);
33
+ expect(r.push(full.slice(cut))).toEqual(['"é"']);
34
+ });
35
+ test("flush() returns a pending line with no trailing newline", () => {
36
+ const r = new NdjsonLineReader();
37
+ r.push(new TextEncoder().encode("partial"));
38
+ expect(r.flush()).toEqual(["partial"]);
39
+ });
40
+ test("flush() strips a trailing carriage return", () => {
41
+ const r = new NdjsonLineReader();
42
+ r.push(new TextEncoder().encode("partial\r"));
43
+ expect(r.flush()).toEqual(["partial"]);
44
+ });
45
+ test("flush() returns [] when nothing is pending", () => {
46
+ expect(new NdjsonLineReader().flush()).toEqual([]);
47
+ });
48
+ test("throws when the pending buffer (no newline yet) exceeds the limit", () => {
49
+ const r = new NdjsonLineReader();
50
+ const huge = "x".repeat(IPC_MAX_LINE_BYTES + 1);
51
+ expect(() => r.push(new TextEncoder().encode(huge))).toThrow("Message exceeds 1MB line limit");
52
+ });
53
+ test("uses the custom lineLimitError constructor", () => {
54
+ class TooBig extends Error {}
55
+ const r = new NdjsonLineReader({ lineLimitError: TooBig });
56
+ const huge = `${"x".repeat(IPC_MAX_LINE_BYTES + 1)}\n`;
57
+ expect(() => r.push(new TextEncoder().encode(huge))).toThrow(TooBig);
58
+ });
59
+ test("flush() drains a partial multi-byte codepoint held by the decoder", () => {
60
+ const r = new NdjsonLineReader();
61
+ expect(r.push(new Uint8Array([0xc3]))).toEqual([]);
62
+ expect(r.flush()).toEqual(["�"]);
63
+ });
64
+ });
@@ -0,0 +1,70 @@
1
+ /** Max bytes per NDJSON line (UTF-8), aligned with IPC protocol limits. */
2
+ export const IPC_MAX_LINE_BYTES = 1024 * 1024;
3
+
4
+ function byteLengthUtf8(s: string): number {
5
+ return new TextEncoder().encode(s).length;
6
+ }
7
+
8
+ export type NdjsonLineReaderOptions = {
9
+ /** When set, oversized lines throw this type instead of `Error`. */
10
+ lineLimitError?: new (
11
+ message: string,
12
+ ) => Error;
13
+ };
14
+
15
+ /**
16
+ * Buffers UTF-8 chunks and emits complete non-empty lines (trailing `\r` stripped).
17
+ * Shared by Gateway JSON-RPC and the CLI IPC client.
18
+ */
19
+ export class NdjsonLineReader {
20
+ private readonly lineLimitCtor: new (
21
+ message: string,
22
+ ) => Error;
23
+ private readonly decoder = new TextDecoder("utf-8", { fatal: false });
24
+ private pending = "";
25
+
26
+ constructor(opts: NdjsonLineReaderOptions = {}) {
27
+ this.lineLimitCtor = opts.lineLimitError ?? Error;
28
+ }
29
+
30
+ private throwLineTooLong(message: string): never {
31
+ throw new this.lineLimitCtor(message);
32
+ }
33
+
34
+ push(chunk: Uint8Array): string[] {
35
+ this.pending += this.decoder.decode(chunk, { stream: true });
36
+ const out: string[] = [];
37
+ while (true) {
38
+ const nl = this.pending.indexOf("\n");
39
+ if (nl < 0) {
40
+ break;
41
+ }
42
+ const line = this.pending.slice(0, nl);
43
+ this.pending = this.pending.slice(nl + 1);
44
+ const trimmed = line.endsWith("\r") ? line.slice(0, -1) : line;
45
+ if (trimmed.length === 0) {
46
+ continue;
47
+ }
48
+ if (byteLengthUtf8(trimmed) > IPC_MAX_LINE_BYTES) {
49
+ this.throwLineTooLong("Message exceeds 1MB line limit");
50
+ }
51
+ out.push(trimmed);
52
+ }
53
+ if (byteLengthUtf8(this.pending) > IPC_MAX_LINE_BYTES) {
54
+ this.throwLineTooLong("Message exceeds 1MB line limit");
55
+ }
56
+ return out;
57
+ }
58
+
59
+ flush(): string[] {
60
+ const rest = this.pending + this.decoder.decode();
61
+ this.pending = "";
62
+ if (rest.length === 0) {
63
+ return [];
64
+ }
65
+ if (byteLengthUtf8(rest) > IPC_MAX_LINE_BYTES) {
66
+ this.throwLineTooLong("Message exceeds 1MB line limit");
67
+ }
68
+ return [rest.endsWith("\r") ? rest.slice(0, -1) : rest];
69
+ }
70
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type {
3
+ AuditEmit,
4
+ AuditLogger,
5
+ ExtensionManifest,
6
+ HitlRequest,
7
+ NimbusItem,
8
+ } from "./index.ts";
9
+ import {
10
+ createScopedAuditLogger,
11
+ ExtensionContractError,
12
+ isHitlRequest,
13
+ MockGateway,
14
+ NimbusExtensionServer,
15
+ runContractTests,
16
+ } from "./index.ts";
17
+
18
+ describe("Plugin API v1 — stable surface", () => {
19
+ test("every v1 export is reachable from the package root", () => {
20
+ expect(typeof createScopedAuditLogger).toBe("function");
21
+ expect(typeof isHitlRequest).toBe("function");
22
+ expect(typeof runContractTests).toBe("function");
23
+ expect(typeof NimbusExtensionServer).toBe("function");
24
+ expect(typeof MockGateway).toBe("function");
25
+ expect(typeof ExtensionContractError).toBe("function");
26
+ });
27
+
28
+ test("v1 types can be used in user code", () => {
29
+ const manifest: ExtensionManifest = {
30
+ id: "ext.example",
31
+ displayName: "Example",
32
+ version: "0.1.0",
33
+ description: "Example extension",
34
+ author: "Nimbus",
35
+ entrypoint: "index.ts",
36
+ runtime: "bun",
37
+ permissions: [],
38
+ hitlRequired: [],
39
+ minNimbusVersion: "0.1.0",
40
+ };
41
+ const item: NimbusItem = { id: "x", service: "test", itemType: "file", name: "n" };
42
+ const emit: AuditEmit = async () => {};
43
+ const logger: AuditLogger = createScopedAuditLogger(manifest.id, emit);
44
+ const hitl: HitlRequest = { actionId: "delete", summary: "Delete one file" };
45
+ expect(manifest.id).toBe("ext.example");
46
+ expect(item.service).toBe("test");
47
+ expect(logger).toBeDefined();
48
+ expect(isHitlRequest(hitl)).toBe(true);
49
+ });
50
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NimbusExtensionServer } from "./server";
3
+
4
+ describe("@nimbus-dev/sdk", () => {
5
+ test("NimbusExtensionServer is constructible", () => {
6
+ const server = new NimbusExtensionServer({
7
+ manifest: {
8
+ id: "test.ext",
9
+ displayName: "Test",
10
+ version: "0.0.1",
11
+ description: "test",
12
+ author: "test",
13
+ entrypoint: "dist/server.js",
14
+ runtime: "bun",
15
+ permissions: ["read"],
16
+ hitlRequired: [],
17
+ minNimbusVersion: "0.1.0",
18
+ },
19
+ });
20
+ expect(server).toBeDefined();
21
+ expect(() => server.start()).not.toThrow();
22
+ });
23
+ });
@@ -0,0 +1,96 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { NimbusExtensionServer } from "./server";
4
+ import type { ExtensionManifest } from "./types";
5
+
6
+ /** Minimal valid ExtensionManifest fixture */
7
+ function makeManifest(id: string): ExtensionManifest {
8
+ return {
9
+ id,
10
+ displayName: "Test Extension",
11
+ version: "1.0.0",
12
+ description: "A test extension",
13
+ author: "tester",
14
+ entrypoint: "index.ts",
15
+ runtime: "bun",
16
+ permissions: ["read"],
17
+ hitlRequired: [],
18
+ minNimbusVersion: "0.1.0",
19
+ };
20
+ }
21
+
22
+ describe("NimbusExtensionServer", () => {
23
+ describe("constructor", () => {
24
+ test("stores options without throwing", () => {
25
+ const manifest = makeManifest("my-ext");
26
+ const server = new NimbusExtensionServer({ manifest });
27
+ // The server object is constructed and is an instance of NimbusExtensionServer
28
+ expect(server).toBeInstanceOf(NimbusExtensionServer);
29
+ });
30
+
31
+ test("stores optional onAuth callback without throwing", () => {
32
+ const manifest = makeManifest("my-ext-with-auth");
33
+ const onAuth = (ctx: { accessToken: string }) => ({ token: ctx.accessToken });
34
+ const server = new NimbusExtensionServer({ manifest, onAuth });
35
+ expect(server).toBeInstanceOf(NimbusExtensionServer);
36
+ });
37
+ });
38
+
39
+ describe("registerTool", () => {
40
+ test("is a no-op and returns void without throwing", () => {
41
+ const server = new NimbusExtensionServer({ manifest: makeManifest("ext-1") });
42
+ const result = server.registerTool("search", {
43
+ description: "Search items",
44
+ inputSchema: { query: { type: "string" } },
45
+ handler: async (input: { query: string }) => ({ results: [input.query] }),
46
+ });
47
+ // registerTool is documented as a no-op — it returns undefined
48
+ expect(result).toBeUndefined();
49
+ });
50
+
51
+ test("accepts multiple tool registrations without throwing", () => {
52
+ const server = new NimbusExtensionServer({ manifest: makeManifest("ext-multi") });
53
+ // registerTool is a documented no-op → each call returns undefined.
54
+ expect(
55
+ server.registerTool("tool-a", {
56
+ description: "Tool A",
57
+ inputSchema: {},
58
+ handler: async (_input: unknown) => "a",
59
+ }),
60
+ ).toBeUndefined();
61
+ expect(
62
+ server.registerTool("tool-b", {
63
+ description: "Tool B",
64
+ inputSchema: { count: { type: "number" } },
65
+ handler: async (_input: unknown) => 42,
66
+ }),
67
+ ).toBeUndefined();
68
+ });
69
+ });
70
+
71
+ describe("start()", () => {
72
+ test("does NOT throw when manifest.id is a non-empty string", () => {
73
+ const server = new NimbusExtensionServer({ manifest: makeManifest("valid-id") });
74
+ // This must not throw — covers the false arm of the id.length === 0 check
75
+ expect(() => server.start()).not.toThrow();
76
+ });
77
+
78
+ test("throws 'manifest.id is required' when manifest.id is empty string", () => {
79
+ const server = new NimbusExtensionServer({ manifest: makeManifest("") });
80
+ // Covers the true arm of the id.length === 0 guard
81
+ expect(() => server.start()).toThrow("NimbusExtensionServer: manifest.id is required");
82
+ });
83
+
84
+ test("thrown error is an instance of Error", () => {
85
+ const server = new NimbusExtensionServer({ manifest: makeManifest("") });
86
+ let caught: unknown;
87
+ try {
88
+ server.start();
89
+ } catch (err) {
90
+ caught = err;
91
+ }
92
+ expect(caught).toBeInstanceOf(Error);
93
+ expect((caught as Error).message).toBe("NimbusExtensionServer: manifest.id is required");
94
+ });
95
+ });
96
+ });
package/src/server.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * NimbusExtensionServer — base class for all Nimbus MCP extension servers
3
+ *
4
+ * Usage:
5
+ * const server = new NimbusExtensionServer({ manifest, onAuth });
6
+ * server.registerTool("search", { description, inputSchema, handler });
7
+ * server.start();
8
+ */
9
+
10
+ import type { ExtensionManifest } from "./types";
11
+
12
+ export interface ExtensionServerOptions<TClient> {
13
+ manifest: ExtensionManifest;
14
+ onAuth?: (ctx: { accessToken: string }) => TClient;
15
+ }
16
+
17
+ export interface ToolDefinition<TInput, TClient> {
18
+ description: string;
19
+ inputSchema: Record<string, unknown>;
20
+ handler: (input: TInput, ctx: { client: TClient }) => Promise<unknown>;
21
+ }
22
+
23
+ export class NimbusExtensionServer<TClient = unknown> {
24
+ private readonly _options: ExtensionServerOptions<TClient>;
25
+
26
+ constructor(options: ExtensionServerOptions<TClient>) {
27
+ this._options = options;
28
+ }
29
+
30
+ registerTool<TInput>(_name: string, _definition: ToolDefinition<TInput, TClient>): void {
31
+ // Roadmap Q3: register tool with MCP server
32
+ }
33
+
34
+ start(): void {
35
+ if (this._options.manifest.id.length === 0) {
36
+ throw new Error("NimbusExtensionServer: manifest.id is required");
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@nimbus-dev/sdk/testing` — utilities for extension authors and the
3
+ * gateway's own connector tests.
4
+ *
5
+ * Exports:
6
+ * - `MockGateway` — mock Gateway IPC for unit tests (Phase 4).
7
+ * - `runSandboxContractTests(manifestPath)` — fork the probe binary and
8
+ * verify the runtime sandbox enforces the manifest's declared
9
+ * `permissions.network` + `permissions.filesystem` (Phase 5 T2 PR 1).
10
+ */
11
+
12
+ export { runSandboxContractTests } from "./sandbox-contract";
13
+
14
+ export class MockGateway {
15
+ async callTool(_toolName: string, _input: Record<string, unknown>): Promise<unknown> {
16
+ return {};
17
+ }
18
+ }