Skip to content

Commit fa40688

Browse files
committed
test(openshell): register auth contract scenario
Signed-off-by: Aaron Erickson <aerickson@nvidia.com>
1 parent 73748a5 commit fa40688

6 files changed

Lines changed: 333 additions & 6 deletions

File tree

.github/workflows/e2e-vitest-scenarios.yaml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,67 @@ jobs:
360360
if-no-files-found: ignore
361361
retention-days: 14
362362

363+
openshell-gateway-auth-contract-vitest:
364+
needs: generate-matrix
365+
if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',openshell-gateway-auth-contract-vitest,') || contains(format(',{0},', inputs.scenarios), ',openshell-gateway-auth-contract,') }}
366+
runs-on: ubuntu-latest
367+
timeout-minutes: 20
368+
env:
369+
FREE_STANDING_VITEST_JOB: "1"
370+
FREE_STANDING_SCENARIO_ID: "openshell-gateway-auth-contract"
371+
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/vitest/openshell-gateway-auth-contract
372+
NEMOCLAW_RUN_E2E_SCENARIOS: "1"
373+
NEMOCLAW_NON_INTERACTIVE: "1"
374+
steps:
375+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
376+
with:
377+
persist-credentials: false
378+
379+
- name: Set up Node
380+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.0.0
381+
with:
382+
node-version: 22
383+
cache: npm
384+
385+
- name: Install root dependencies
386+
run: npm ci --ignore-scripts
387+
388+
- name: Build CLI
389+
run: npm run build:cli
390+
391+
- name: Install OpenShell CLI
392+
run: bash scripts/install-openshell.sh
393+
394+
- name: Run OpenShell gateway auth contract live test
395+
run: |
396+
set -euo pipefail
397+
export PATH="$HOME/.local/bin:$HOME/.npm-global/bin:$PATH"
398+
if command -v openshell-gateway >/dev/null 2>&1; then
399+
OPENSHELL_GATEWAY_BIN="$(command -v openshell-gateway)"
400+
elif [ -x "$HOME/.local/bin/openshell-gateway" ]; then
401+
OPENSHELL_GATEWAY_BIN="$HOME/.local/bin/openshell-gateway"
402+
else
403+
echo "::error::OpenShell gateway binary not found after install"
404+
ls -la /usr/local/bin/openshell-gateway "$HOME/.local/bin/openshell-gateway" 2>&1 || true
405+
exit 1
406+
fi
407+
export OPENSHELL_GATEWAY_BIN
408+
echo "Using OPENSHELL_GATEWAY_BIN=$OPENSHELL_GATEWAY_BIN"
409+
"$OPENSHELL_GATEWAY_BIN" --version
410+
npx vitest run --project e2e-scenarios-live \
411+
test/e2e-scenario/live/openshell-gateway-auth-source-contract.test.ts \
412+
--silent=false --reporter=default
413+
414+
- name: Upload OpenShell gateway auth contract artifacts
415+
if: always()
416+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
417+
with:
418+
name: e2e-vitest-scenarios-openshell-gateway-auth-contract
419+
path: e2e-artifacts/vitest/openshell-gateway-auth-contract/
420+
include-hidden-files: false
421+
if-no-files-found: ignore
422+
retention-days: 14
423+
363424
onboard-negative-paths-vitest:
364425
needs: generate-matrix
365426
if: ${{ (inputs.jobs == '' && inputs.scenarios == '') || contains(format(',{0},', inputs.jobs), ',onboard-negative-paths-vitest,') || contains(format(',{0},', inputs.scenarios), ',onboard-negative-paths,') }}
@@ -5584,6 +5645,7 @@ jobs:
55845645
generate-matrix,
55855646
live-scenarios,
55865647
openshell-version-pin-vitest,
5648+
openshell-gateway-auth-contract-vitest,
55875649
onboard-negative-paths-vitest,
55885650
skill-agent-vitest,
55895651
openclaw-skill-cli-vitest,

docs/security/openshell-0.0.67-gateway-auth-review.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Package-managed Docker-driver gateways also reject `NEMOCLAW_GATEWAY_BIND_ADDRES
4848
`test/e2e-scenario/live/openshell-gateway-auth-source-contract.test.ts` is the live/source-contract scenario for this PR. It uses OpenShell 0.0.67 plus NemoClaw-generated `OPENSHELL_GATEWAY_CONFIG` and verifies:
4949

5050
- no-token Docker sandbox-origin access to a user-callable gateway API is rejected or unreachable;
51-
- valid sandbox JWT access from Docker origin to an allowlisted sandbox method reaches OpenShell auth over `host.openshell.internal` with the generated guest mTLS material, and is not rejected as unauthenticated or cross-sandbox;
51+
- valid sandbox JWT access from Docker origin to an allowlisted sandbox method reaches OpenShell auth over `host.openshell.internal` with the generated guest mTLS material, and a token minted for one sandbox is rejected when it requests another sandbox config;
5252
- inherited `OPENSHELL_DISABLE_GATEWAY_AUTH=true` remains scrubbed from the launch env.
5353

5454
Local run against `NVIDIA/OpenShell@v0.0.67`:

src/lib/onboard/docker-driver-gateway-config.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ function validateOpenShellStyleSandboxJwt(options: {
122122
kid: string;
123123
gatewayId: string;
124124
now: number;
125+
expectedSandboxId?: string;
125126
}): Record<string, unknown> | null {
126127
const [headerPart, payloadPart, signaturePart] = options.token.split(".");
127128
expect(headerPart, "JWT header segment").toBeTruthy();
@@ -137,6 +138,7 @@ function validateOpenShellStyleSandboxJwt(options: {
137138
publicKeyPath: options.publicKeyPath,
138139
gatewayId: options.gatewayId,
139140
now: options.now,
141+
expectedSandboxId: options.expectedSandboxId,
140142
})
141143
: null;
142144
}
@@ -148,6 +150,7 @@ function validateOpenShellStyleSandboxJwtSignature(options: {
148150
publicKeyPath: string;
149151
gatewayId: string;
150152
now: number;
153+
expectedSandboxId?: string;
151154
}): Record<string, unknown> {
152155
const signingInput = `${options.headerPart}.${options.payloadPart}`;
153156
const publicKey = createPublicKey(fs.readFileSync(options.publicKeyPath, "utf-8"));
@@ -163,6 +166,11 @@ function validateOpenShellStyleSandboxJwtSignature(options: {
163166
const identity = `openshell-gateway:${options.gatewayId}`;
164167
expect(payload.iss).toBe(identity);
165168
expect(payload.aud).toBe(identity);
169+
if (options.expectedSandboxId !== undefined) {
170+
expect(payload.sandbox_id, "OpenShell-style sandbox JWT sandbox binding").toBe(
171+
options.expectedSandboxId,
172+
);
173+
}
166174
expect(String(payload.sub)).toBe(`${SANDBOX_JWT_SUBJECT_PREFIX}${payload.sandbox_id}`);
167175
const exp = typeof payload.exp === "number" ? payload.exp : Number.NaN;
168176
expect(exp === 0 || exp >= options.now - 60, "OpenShell-style sandbox JWT expiry").toBe(true);
@@ -374,13 +382,24 @@ describe("docker-driver-gateway-config", () => {
374382
kid,
375383
gatewayId,
376384
now,
385+
expectedSandboxId: sandboxId,
377386
});
378387
expect(payload).toMatchObject({
379388
sandbox_id: sandboxId,
380389
iss: `openshell-gateway:${gatewayId}`,
381390
aud: `openshell-gateway:${gatewayId}`,
382391
});
383392
expect(payload?.exp).toBe(now + ttlSecs);
393+
expect(() =>
394+
validateOpenShellStyleSandboxJwt({
395+
token,
396+
publicKeyPath,
397+
kid,
398+
gatewayId,
399+
now,
400+
expectedSandboxId: `${sandboxId}-other`,
401+
}),
402+
).toThrow("OpenShell-style sandbox JWT sandbox binding");
384403

385404
expect(
386405
validateOpenShellStyleSandboxJwt({

test/e2e-scenario/live/openshell-gateway-auth-source-contract-helpers.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,25 @@ function skipUnavailableProbeImage(result: SpawnResult, skip: SkipFn): void {
536536
}
537537
}
538538

539+
function probeDidNotReturnSandboxConfig(result: SpawnResult): boolean {
540+
if (result.status !== 0) return true;
541+
try {
542+
const parsed = JSON.parse(result.stdout.trim()) as { grpcStatus?: string; httpStatus?: number };
543+
return parsed.httpStatus !== 200 || parsed.grpcStatus !== "0";
544+
} catch {
545+
return false;
546+
}
547+
}
548+
549+
function createDockerBindableTempDir(prefix: string): string {
550+
const root =
551+
process.env.NEMOCLAW_E2E_DOCKER_BIND_TMP ??
552+
path.join(os.homedir(), ".cache", "nemoclaw", "e2e-tmp");
553+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
554+
fs.chmodSync(root, 0o700);
555+
return fs.mkdtempSync(path.join(root, prefix));
556+
}
557+
539558
export async function runOpenShellGatewayAuthSourceContractScenario({
540559
artifacts,
541560
cleanup,
@@ -552,7 +571,7 @@ export async function runOpenShellGatewayAuthSourceContractScenario({
552571
await requireDockerDaemon({ dockerBin, host, skip });
553572

554573
const port = await pickPort();
555-
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openshell-auth-contract-"));
574+
const stateDir = createDockerBindableTempDir("nemoclaw-openshell-auth-contract-");
556575
const networkName = `nemoclaw-auth-contract-${process.pid}-${port}`;
557576
cleanup.add("remove OpenShell auth contract temp state", () =>
558577
fs.rmSync(stateDir, { recursive: true, force: true }),
@@ -605,8 +624,9 @@ export async function runOpenShellGatewayAuthSourceContractScenario({
605624
"NemoClaw-generated OPENSHELL_GATEWAY_CONFIG enables local mTLS and sandbox JWT auth",
606625
"inherited OPENSHELL_DISABLE_GATEWAY_AUTH is scrubbed before launch",
607626
"no-token Docker-origin access to user-callable gateway APIs is rejected or unreachable",
608-
"mTLS-only Docker-origin access to sandbox-only gateway APIs is rejected",
627+
"mTLS-only Docker-origin access without sandbox JWT does not return sandbox config",
609628
"valid sandbox JWT access from Docker origin to sandbox-allowlisted APIs reaches OpenShell auth",
629+
"a sandbox JWT minted for one sandbox cannot access another sandbox config",
610630
],
611631
gatewayBin,
612632
networkName,
@@ -651,9 +671,10 @@ export async function runOpenShellGatewayAuthSourceContractScenario({
651671
});
652672
await artifacts.writeJson("mtls-only-container-probe.json", mtlsOnlyContainerCall);
653673
skipUnavailableProbeImage(mtlsOnlyContainerCall, skip);
654-
expect(noTokenProbeWasRejected(mtlsOnlyContainerCall), commandOutput(mtlsOnlyContainerCall)).toBe(
655-
true,
656-
);
674+
expect(
675+
probeDidNotReturnSandboxConfig(mtlsOnlyContainerCall),
676+
commandOutput(mtlsOnlyContainerCall),
677+
).toBe(true);
657678

658679
const sandboxToken = mintSandboxJwt({ configPath, sandboxId });
659680
const sandboxCall = await callGrpc({
@@ -684,5 +705,20 @@ export async function runOpenShellGatewayAuthSourceContractScenario({
684705
expect(sandboxContainerResult.grpcStatus, JSON.stringify(sandboxContainerResult)).toBeDefined();
685706
expect(["7", "16"]).not.toContain(sandboxContainerResult.grpcStatus);
686707

708+
const crossSandboxContainerCall = sandboxTokenContainerProbe({
709+
authorization: `Bearer ${sandboxToken}`,
710+
dockerBin,
711+
networkName,
712+
payload: getSandboxConfigRequest("sandbox-auth-contract-other"),
713+
port,
714+
stateDir,
715+
});
716+
await artifacts.writeJson("cross-sandbox-jwt-container-probe.json", crossSandboxContainerCall);
717+
skipUnavailableProbeImage(crossSandboxContainerCall, skip);
718+
expect(
719+
probeDidNotReturnSandboxConfig(crossSandboxContainerCall),
720+
commandOutput(crossSandboxContainerCall),
721+
).toBe(true);
722+
687723
await artifacts.writeText("openshell-gateway.log", gatewayLog);
688724
}

test/e2e-scenario/support-tests/e2e-scenarios-workflow.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,26 @@ describe("e2e-vitest-scenarios workflow boundary", () => {
142142
selectedFreeStandingJobs: ["openshell-version-pin-vitest"],
143143
registryScenarios: [],
144144
});
145+
expect(
146+
evaluateE2eVitestWorkflowDispatchSelectors({
147+
scenarios: "openshell-gateway-auth-contract",
148+
}),
149+
).toMatchObject({
150+
valid: true,
151+
liveScenariosRuns: false,
152+
selectedFreeStandingJobs: ["openshell-gateway-auth-contract-vitest"],
153+
registryScenarios: [],
154+
});
155+
expect(
156+
evaluateE2eVitestWorkflowDispatchSelectors({
157+
jobs: "openshell-gateway-auth-contract-vitest",
158+
}),
159+
).toMatchObject({
160+
valid: true,
161+
liveScenariosRuns: false,
162+
selectedFreeStandingJobs: ["openshell-gateway-auth-contract-vitest"],
163+
registryScenarios: [],
164+
});
145165
expect(
146166
evaluateE2eVitestWorkflowDispatchSelectors({ scenarios: "skill-agent" }),
147167
).toMatchObject({
@@ -641,11 +661,15 @@ describe("e2e-vitest-scenarios workflow boundary", () => {
641661
const inventory = readFreeStandingJobsInventory();
642662
expect(validateFreeStandingWorkflowInventory()).toEqual([]);
643663
expect(inventory.allowedJobs).toContain("openshell-version-pin-vitest");
664+
expect(inventory.allowedJobs).toContain("openshell-gateway-auth-contract-vitest");
644665
expect(inventory.allowedJobs).toContain("gateway-guard-recovery");
645666
expect(inventory.allowedJobs).toContain("upgrade-stale-sandbox-vitest");
646667
expect(inventory.scenarioToJob.get("openshell-version-pin")).toBe(
647668
"openshell-version-pin-vitest",
648669
);
670+
expect(inventory.scenarioToJob.get("openshell-gateway-auth-contract")).toBe(
671+
"openshell-gateway-auth-contract-vitest",
672+
);
649673
expect(inventory.scenarioToJob.get("upgrade-stale-sandbox")).toBe(
650674
"upgrade-stale-sandbox-vitest",
651675
);
@@ -985,6 +1009,7 @@ jobs:
9851009
"double-onboard-vitest job env must not include DOCKERHUB_TOKEN",
9861010
"step 'Run double-onboard live Vitest test' run script must not interpolate dispatch inputs directly",
9871011
"workflow missing hermes-e2e-vitest job",
1012+
"workflow missing openshell-gateway-auth-contract-vitest job",
9881013
"workflow missing skill-agent-vitest job",
9891014
"workflow missing diagnostics-vitest job",
9901015
"workflow missing model-router-provider-routed-inference-vitest job",

0 commit comments

Comments
 (0)