Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ repos:
pass_filenames: true
priority: 10

- id: oxlint-adapters-type-aware
name: Oxlint adapter type-aware rules
entry: npx oxlint --config oxlint.type-aware.config.ts --tsconfig tsconfig.cli.json --no-error-on-unmatched-pattern
language: system
files: ^src/lib/adapters/.*\.ts$
pass_filenames: true
priority: 10

- id: validate-config-schemas
name: Validate config files against JSON schemas
entry: npx tsx scripts/validate-configs.mts
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,11 @@ Every source file needs the repository SPDX header; the pre-commit hook inserts
- `bin/` launcher and remaining `scripts/*.js`: **CommonJS** (`require`/`module.exports`), Node.js 22.19+
- `test/`: **ESM** (`import`/`export`)
- Do not add new JavaScript source files. Prefer TypeScript when modifying existing JavaScript. New test files must use TypeScript.
- Oxlint uses `oxlint.config.ts`. The isolated `oxlint.type-aware.config.ts` configuration enforces `typescript/no-floating-promises` for plugin sources.
- Oxlint uses `oxlint.config.ts`. The isolated `oxlint.type-aware.config.ts` configuration checks promises in plugin and adapter sources.
Adapter checks use `--tsconfig tsconfig.cli.json` and also reject misused promises, invalid awaits, and incomplete switches.
- Adapter sources and tests require type-only imports and exports, strict equality, and no unused variables or explicit `any`.
Production adapters also reject non-null assertions and nested ternaries.
- Oxfmt covers all `src/lib/adapters` files. The formatting hook also formats changes to existing adapters.

- Use `eslint-plugin-sonarjs` only for the `oxlint.config.ts` cognitive-complexity rules documented in [`tools/lint/DEPENDENCY-REVIEW.md`](tools/lint/DEPENDENCY-REVIEW.md).
- Keep function complexity low; existing complexity hotspots are tracked separately
Expand Down
26 changes: 26 additions & 0 deletions oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,32 @@ export default defineConfig({
"no-nested-ternary": "error",
},
},
{
files: ["src/lib/adapters/**/*.ts"],
rules: {
"no-unused-vars": "error",
"no-debugger": "error",
eqeqeq: "error",
"typescript/no-explicit-any": "error",
"typescript/consistent-type-exports": "error",
"typescript/consistent-type-imports": [
"error",
{
disallowTypeAnnotations: false,
fixStyle: "separate-type-imports",
prefer: "type-imports",
},
],
},
},
{
files: ["src/lib/adapters/**/*.ts"],
excludeFiles: ["**/*.test.ts"],
rules: {
"no-nested-ternary": "error",
"typescript/no-non-null-assertion": "error",
},
},
// Pin the migration-baseline SonarJS scores for existing hotspots so later changes cannot increase them.

{
Expand Down
11 changes: 11 additions & 0 deletions oxlint.type-aware.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ export default defineConfig({
typeAware: true,
},
plugins: ["typescript"],
overrides: [
{
files: ["src/lib/adapters/**/*.ts"],
rules: {
"typescript/no-misused-promises": "error",
"typescript/consistent-type-exports": "error",
"typescript/await-thenable": "error",
"typescript/switch-exhaustiveness-check": "error",
},
},
],
rules: {
"typescript/no-floating-promises": "error",
},
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@
"check:diff": "npm run validate:pr",
"checks:repository": "tsx scripts/checks/run.mts",
"checks": "node -e \"console.error('npm run checks runs only narrow repository checks. Use npm run validate:pr for routine PR validation or npm run checks:repository for the narrow runner.')\" && npm run checks:repository",
"lint": "oxlint . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && npm run format:check && npm run checks:repository",
"lint:fix": "oxlint --fix . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && npm run format && npm run checks:repository",
"lint": "oxlint . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && oxlint --config oxlint.type-aware.config.ts --tsconfig tsconfig.cli.json src/lib/adapters && npm run format:check && npm run checks:repository",
"lint:fix": "oxlint --fix . .dsh/tools && oxlint --config oxlint.type-aware.config.ts nemoclaw/src && oxlint --config oxlint.type-aware.config.ts --tsconfig tsconfig.cli.json src/lib/adapters && npm run format && npm run checks:repository",
"lint:ts": "cd nemoclaw && npm run check",
"format": "bash tools/lint/format-added-files.sh --write",
"format:check": "bash tools/lint/format-added-files.sh --check",
Expand Down
87 changes: 59 additions & 28 deletions src/lib/adapters/config/live-export-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ vi.mock("../openshell/sdk", () => ({ connectManagedOpenShellSdk: vi.fn() }));
vi.mock("../openshell/sanitized-capture", () => ({
captureSanitizedResolvedOpenshell: vi.fn(),
}));
vi.mock("../openshell/sandbox-policy-cli", () => ({
syncCliOpenShellSandboxPolicyReader: { readSandboxPolicy: vi.fn() },
}));
vi.mock("../openshell/sandbox-config", async (importOriginal) => {
const actual = await importOriginal<typeof import("../openshell/sandbox-config")>();
return {
...actual,
createSandboxConfig: () =>
actual.createSandboxConfig(undefined, async (policy) => YAML.stringify(policy)),
};
});
vi.mock("../../onboard/gateway/state-dir", () => ({
managedGatewayStateRootOwnershipFailure: vi.fn(() => null),
resolveGatewayStateDirForPort: vi.fn(() => "/managed/gateway"),
Expand All @@ -35,7 +40,6 @@ import { connectManagedOpenShellSdk } from "../openshell/sdk";
import { observeStableExportSource } from "../../actions/config/observe-export-source";
import { captureSanitizedResolvedOpenshell } from "../openshell/sanitized-capture";
import { fingerprintOpenShellSandboxId } from "../openshell/sandbox-identity";
import { syncCliOpenShellSandboxPolicyReader } from "../openshell/sandbox-policy-cli";
import { createLiveExportSnapshotReader } from "./live-export-source";

const sandboxId = "123e4567-e89b-42d3-a456-426614174000";
Expand Down Expand Up @@ -142,6 +146,18 @@ function provider() {
}
function configuration(revision = 3) {
return {
policy: {
version: 1,
process: { run_as_user: "sandbox", run_as_group: "sandbox" },
filesystem_policy: { include_workdir: false, read_only: ["/usr"], read_write: ["/sandbox"] },
network_policies: {
api: {
name: "api",
endpoints: [{ host: "api.example.com", port: 443 }],
binaries: [{ path: "/usr/bin/curl" }],
},
},
},
workspace: "default",
version: revision,
policyHash: "a".repeat(64),
Expand Down Expand Up @@ -175,15 +191,7 @@ function mockSupportedLiveSource(
vi.mocked(connectManagedOpenShellSdk).mockResolvedValue({ raw });
raw.getProvider.mockResolvedValue(provider());
raw.getSandbox.mockResolvedValue(inventory(7, policyVersion));
raw.getSandboxConfig.mockResolvedValue(configuration(policyVersion));
vi.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy).mockReturnValue({
ok: true,
value: {
document:
"version: 1\nprocess:\n run_as_user: sandbox\n run_as_group: sandbox\nnetwork_policies:\n api:\n name: api\n endpoints: [{host: api.example.com, port: 443}]\n binaries: [{path: /usr/bin/curl}]\nfilesystem_policy:\n include_workdir: false\n read_only: [/usr]\n read_write: [/sandbox]\n",
appliedRevision,
},
});
raw.getSandboxConfig.mockResolvedValue(configuration(appliedRevision));
}

function nativeNvidiaProvider() {
Expand Down Expand Up @@ -255,11 +263,9 @@ describe("live export snapshot reader", () => {
{
stage: "effective-policy",
fail: () =>
vi
.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy)
.mockImplementationOnce(() => {
throw new Error(readFailureCanary);
}),
raw.getSandboxConfig.mockImplementationOnce(() => {
throw new Error(readFailureCanary);
}),
},
] as const)("tags a sanitized $stage read failure", async ({ stage, fail }) => {
mockSupportedLiveSource();
Expand Down Expand Up @@ -325,10 +331,6 @@ describe("live export snapshot reader", () => {
const first = await reader.read("alpha");
raw.getSandbox.mockResolvedValue(inventory(8, 4));
raw.getSandboxConfig.mockResolvedValue(configuration(4));
vi.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy).mockReturnValue({
ok: true,
value: { document: "version: 1\nnetwork_policies: {}\n", appliedRevision: 4 },
});

const second = await reader.read("alpha");

Expand All @@ -347,12 +349,9 @@ describe("live export snapshot reader", () => {
it("sanitizes credential-bearing policy failures", async () => {
mockSupportedLiveSource();
const canary = "credential-canary-value";
vi.mocked(syncCliOpenShellSandboxPolicyReader.readSandboxPolicy).mockReturnValue({
ok: true,
value: {
document: `version: 1\nenv: {TOKEN: "${canary}"}\nnetwork_policies: {}\n`,
appliedRevision: 3,
},
raw.getSandboxConfig.mockResolvedValue({
...configuration(),
policy: { ...configuration().policy, env: { TOKEN: canary } },
});

const result = await createLiveExportSnapshotReader().read("alpha");
Expand All @@ -376,6 +375,37 @@ describe("live export snapshot reader", () => {
});
});

it.each([0, 3])(
"preserves global policy revision agreement for revision %i",
async (globalPolicyVersion) => {
mockSupportedLiveSource();
raw.getSandboxConfig.mockResolvedValue({
...configuration(),
policySource: 2,
globalPolicyVersion,
});
const result = await createLiveExportSnapshotReader().read("alpha");
expect(result).toMatchObject({
kind: "observed",
policy: { revision: "3" },
configuration: { policySource: "global", globalPolicyVersion },
});
},
);

it("rejects a global policy revision that differs from the sandbox revision", async () => {
mockSupportedLiveSource();
raw.getSandboxConfig.mockResolvedValue({
...configuration(),
policySource: 2,
globalPolicyVersion: 4,
});
await expect(createLiveExportSnapshotReader().read("alpha")).resolves.toEqual({
kind: "read-failed",
stage: "effective-policy",
});
});

it("maps route and policy revision drift to controlled read failures", async () => {
mockSupportedLiveSource(4, 3);
await expect(createLiveExportSnapshotReader().read("alpha")).resolves.toEqual({
Expand Down Expand Up @@ -515,6 +545,7 @@ describe("live export snapshot reader", () => {
},
});
expect(raw.getProvider).toHaveBeenCalledTimes(2);
expect(raw.getSandboxConfig).toHaveBeenCalledTimes(2);
expect(JSON.stringify(result)).not.toContain(readFailureCanary);
});

Expand Down
29 changes: 9 additions & 20 deletions src/lib/adapters/config/live-export-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { createSandboxConfig } from "../openshell/sandbox-config";
import { captureSanitizedResolvedOpenshell } from "../openshell/sanitized-capture";
import { fingerprintOpenShellSandboxId } from "../openshell/sandbox-identity";
import { namedOpenShellGateway } from "../openshell/sandbox-observer";
import { syncCliOpenShellSandboxPolicyReader } from "../openshell/sandbox-policy-cli";
import { EXPORT_REGISTRY_EVIDENCE_KEYS } from "../../domain/config/export-evidence";
import type {
ExportSnapshotReadStage,
Expand Down Expand Up @@ -193,39 +192,29 @@ async function inferenceFor(
};
}

async function effectivePolicy(
sandboxName: string,
gateway: ObservedExportGateway,
row: Sandbox,
signal: AbortSignal,
) {
const configuration = await createSandboxConfig().get({
async function effectivePolicy(gateway: ObservedExportGateway, row: Sandbox, signal: AbortSignal) {
const { policy, ...configuration } = await createSandboxConfig().get({
target: namedOpenShellGateway(gateway.name),
workspace: row.workspace,
sandboxId: row.id,
signal,
});
const result = syncCliOpenShellSandboxPolicyReader.readSandboxPolicy({
target: namedOpenShellGateway(gateway.name),
sandboxName,
scope: "effective",
});
if (!result.ok || result.value.appliedRevision === null) {
if (policy.appliedRevision === null) {
throw new Error("The effective OpenShell policy and its applied revision could not be read.");
}
if (!isSandboxPolicyCredentialFree(result.value.document)) {
if (!isSandboxPolicyCredentialFree(policy.document)) {
throw new Error("The effective OpenShell policy is not credential-free.");
}
if (
row.policyVersion !== result.value.appliedRevision ||
configuration.revision !== result.value.appliedRevision
row.policyVersion !== policy.appliedRevision ||
configuration.revision !== policy.appliedRevision
) {
throw new Error("The effective OpenShell policy revision does not match the live sandbox.");
}
return {
sandboxId: row.id,
revision: String(result.value.appliedRevision),
document: result.value.document,
revision: String(policy.appliedRevision),
document: policy.document,
configuration,
};
}
Expand Down Expand Up @@ -259,7 +248,7 @@ async function readSnapshot(sandboxName: string): Promise<RawExportSnapshot> {
signal,
);
stage = "effective-policy";
const { configuration, ...policy } = await effectivePolicy(sandboxName, gateway, row, signal);
const { configuration, ...policy } = await effectivePolicy(gateway, row, signal);
return {
kind: "observed",
sandboxName,
Expand Down
10 changes: 8 additions & 2 deletions src/lib/adapters/docker/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,16 @@ export function dockerBuild(
...(quiet ? ["--quiet"] : []),

...Object.entries(buildArgs ?? {})
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.sort(([left], [right]) => {
if (left < right) return -1;
return left > right ? 1 : 0;
})
.flatMap(([key, value]) => ["--build-arg", `${key}=${value}`]),
...Object.entries(labels ?? {})
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.sort(([left], [right]) => {
if (left < right) return -1;
return left > right ? 1 : 0;
})
.flatMap(([key, value]) => ["--label", `${key}=${value}`]),
"-f",
dockerfilePath,
Expand Down
17 changes: 8 additions & 9 deletions src/lib/adapters/http/auth-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,14 @@ describe("curl auth config helper", () => {
}
});

it.each([
" : value",
"Bad Header: value",
"missing-colon",
])("rejects invalid OpenAI-like provider header %j", (header) => {
expect(() => parseOpenAiLikeExtraHeaders([header])).toThrow(
"invalid OpenAI-like provider header",
);
});
it.each([" : value", "Bad Header: value", "missing-colon"])(
"rejects invalid OpenAI-like provider header %j",
(header) => {
expect(() => parseOpenAiLikeExtraHeaders([header])).toThrow(
"invalid OpenAI-like provider header",
);
},
);

it("accepts every HTTP token character in an OpenAI-like provider header name", () => {
const tokenChars =
Expand Down
24 changes: 10 additions & 14 deletions src/lib/adapters/http/container-curl-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,11 @@ describe("container curl probe", () => {
it.each(["20x", "200 extra"])(
"rejects a malformed HTTP status write-out without creating the response file: %s (#9116)",
(httpStatus) => {
const spawn = vi.fn(
(_command: string, args: readonly string[]) => {
const writeOutIndex = args.indexOf("-w");
const writeOut = args[writeOutIndex + 1];
return successfulSpawn(`{}${writeOut.replace("%{http_code}", httpStatus)}`);
},
);
const spawn = vi.fn((_command: string, args: readonly string[]) => {
const writeOutIndex = args.indexOf("-w");
const writeOut = args[writeOutIndex + 1];
return successfulSpawn(`{}${writeOut.replace("%{http_code}", httpStatus)}`);
});
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-probe-test-"));
const outputPath = path.join(tempDir, "response.json");

Expand Down Expand Up @@ -144,13 +142,11 @@ describe("container curl probe", () => {
});

it("does not follow a replacement output symlink (#9116)", () => {
const spawn = vi.fn(
(_command: string, args: readonly string[]) => {
const writeOutIndex = args.indexOf("-w");
const writeOut = args[writeOutIndex + 1];
return successfulSpawn(`replacement${writeOut.replace("%{http_code}", "200")}`);
},
);
const spawn = vi.fn((_command: string, args: readonly string[]) => {
const writeOutIndex = args.indexOf("-w");
const writeOut = args[writeOutIndex + 1];
return successfulSpawn(`replacement${writeOut.replace("%{http_code}", "200")}`);
});
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-curl-probe-test-"));
const targetPath = path.join(tempDir, "target.json");
const outputPath = path.join(tempDir, "response.json");
Expand Down
Loading
Loading