Skip to content

Commit d05809f

Browse files
deepujainrsliterprekshivyas
authored
fix(e2e): retry exact artifact downloads (#9353)
## Summary Retry transient reads of the exact Deep Agents Code contract artifact selected by the trusted base-image publication gate. The download remains bound to one artifact identity and fails closed for identity, integrity, authorization, archive, contract, and exhaustion failures. ## Related Issue Fixes #9340 ## Changes - Bind the artifact ID, name, size, digest, producer run, attempt, and head SHA before reading content. - Retry only transport failures, HTTP 408, HTTP 429, and HTTP 5xx for the same artifact ID, with at most three attempts and bounded `Retry-After` handling. - Validate archive size, digest, ZIP structure, and the existing contract before consumers can run. - Record the distinct retry contract and protect the trusted workflow boundary with deterministic tests. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [ ] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## DGX Station Hardware Evidence - [ ] Tested on DGX Station - Tested commit: - Station profile/scenario: - Result: - Supporting evidence: ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — `npx vitest run test/e2e/support/exact-artifact-download.test.ts test/e2e/support/base-image-publication-workflow-boundary.test.ts` (66 passed) - [x] Applicable broad gate passed — `npm run test:changed` (433 passed); `npm run build:cli`; `npm run typecheck:cli`; `npm run lint` - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) --- Signed-off-by: Deepak Jain <deepujain@gmail.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added secure contract artifact retrieval with exact identity, integrity, expiration, and archive validation. * Added bounded retries for transient download failures while keeping validation errors terminal. * Added fail-closed validation and secure extraction of the required contract file. * **Bug Fixes** * Improved protection against metadata mismatches, digest failures, malformed archives, artifact ambiguity, and sensitive error logging. * Updated contract retrieval to use immutable publication metadata and validated download sources. * **Tests** * Added comprehensive end-to-end coverage for artifact binding, retries, integrity checks, archive handling, and contract validation. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Deepak Jain <deepujain@gmail.com> Signed-off-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Signed-off-by: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com> Co-authored-by: Rebecca Sliter <571084+rsliter@users.noreply.github.com> Co-authored-by: Prekshi Vyas <34834085+prekshivyas@users.noreply.github.com>
1 parent 1eb2091 commit d05809f

7 files changed

Lines changed: 629 additions & 20 deletions

.github/workflows/e2e.yaml

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,12 @@ jobs:
168168

169169
- name: Download immutable Deep Agents Code base contract
170170
if: ${{ steps.publication_mode.outputs.required == '1' }}
171-
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
172-
with:
173-
github-token: ${{ github.token }}
174-
name: managed-base-${{ steps.publication.outputs.run_id }}-${{ steps.publication.outputs.run_attempt }}-langchain-deepagents-code
175-
path: ${{ runner.temp }}/dcode-base-contract
176-
repository: NVIDIA/NemoClaw
177-
run-id: ${{ steps.publication.outputs.run_id }}
171+
env:
172+
GITHUB_TOKEN: ${{ github.token }}
173+
PUBLICATION_HEAD_SHA: ${{ steps.publication.outputs.head_sha }}
174+
PUBLICATION_RUN_ATTEMPT: ${{ steps.publication.outputs.run_attempt }}
175+
PUBLICATION_RUN_ID: ${{ steps.publication.outputs.run_id }}
176+
run: node --experimental-strip-types --no-warnings tools/e2e/exact-artifact-download.mts "${RUNNER_TEMP}/dcode-base-contract"
178177

179178
- id: validate_dcode_base
180179
name: Validate immutable Deep Agents Code base

test/e2e/RETRY_INVENTORY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Exhaustion remains failed.
1818
| `pr-rerun-reconciliation` | PR E2E dispatch reconciliation; `tools/e2e/pr-e2e-dispatch-reconciliation.mts`, `tools/e2e/pr-e2e-retry-receipt.mts` | Trusted dispatch receipt state | Contract-defined single reconciliation | Reconciles workflow and commit identity before action | GitHub Actions | Receipt-specific terminal states | Signed workflow identity and receipt | External scope; governed by #7206 |
1919
| `github-publication-read` | GitHub API reads; `tools/e2e/base-image-publication.mts` | Fetch error, 408, rate limit, or 5xx | 3 attempts; Retry-After/rate-limit reset or linear delay capped at 10s | Read-only | GitHub API | Returned parsed selection on success; thrown terminal HTTP/fetch error on failure or exhaustion | Caller artifact records the returned publication selection; terminal errors identify exhausted fetch or HTTP status without response content | Eligible bounded read; existing implementation retained |
2020
| `trusted-controller-collaborator-permission-read` | Collaborator-permission reads for manual PR dispatch, release waiver, and Launchable publication; `.github/workflows/e2e.yaml` | Curl exit 5, 6, 7, 16, 18, 28, 35, 52, 55, 56, 92, 95, or 96; HTTP 408, 429, or 5xx | 3 attempts; linear 1s then 2s | Read-only GitHub API request | GitHub API | Transient API read versus terminal authentication, authorization, actor, or response failure | Operation name, attempt number, and sanitized failure class or HTTP status; no response body, header, or token | Eligible bounded read; HTTP 401, 403, 404, and 422, malformed responses, actor failures, and insufficient roles remain terminal; no cached permission or workflow rerun |
21+
| `github-exact-artifact-content-read` | Bound Deep Agents Code contract artifact; `tools/e2e/exact-artifact-download.mts` | Transport failure, HTTP 408, HTTP 429, or HTTP 5xx while reading one pre-bound artifact ID | 3 attempts; Retry-After or linear delay capped at 10s | Read-only request against one immutable artifact ID, name, size, digest, producer run, attempt, and head | GitHub artifact service | `passed-first-attempt`, `passed-after-retry`, `exhausted` for transient exhaustion, or `failed-no-retry` for terminal HTTP; identity, size, digest, archive, and contract failures throw without an aggregate outcome or `failureClass` | Content-read attempts log only the sanitized operation, attempt, HTTP status or transport class, and outcome; thrown validation failures expose only their bounded error message, never headers, body, token, signed URL, or artifact content | Standalone bounded content read; it does not use `retry-policy.ts` or `RetryEvidence`, and all identity, integrity, archive, and contract failures remain terminal |
2122
| `inference-switch-ts` | Verified inference route update; `test/e2e/fixtures/inference-switch-retry.ts` | Timeout, reset, DNS/connectivity/connect error, request transport error, or exact 502/503/504 status; authentication, authorization, policy, malformed-input, and invalid-request signals take precedence | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Shared `RetryEvidence` classifications | Every attempt classification and aggregate outcome; command artifacts remain separate and redacted | Uses `runBoundedRetry`; deterministic verification mismatches stop; no `--no-verify` exhaustion bypass |
2223
| `inference-switch-shell` | Verified shell inference route update; `test/e2e/lib/inference-switch-retry.sh` | Same bounded transient and terminal-precedence signatures as the TypeScript helper | 1-10 attempts; linear 5s | Setting the same desired provider/model is idempotent | Inference provider | Exit status remains failed on exhaustion | Existing command output and retry progress | Bounded compatibility helper; no `--no-verify` exhaustion bypass |
2324
| `provider-install-standard` | Provider validation during Brave, cron, device-auth, Hermes-switch, network-policy, and restricted onboarding | `isTransientProviderValidationFailure` allowlist only | 1 local or 3 CI attempts; linear 10s backoff | Repeats the same desired onboarding state; restricted paths destroy the prior sandbox before retry | Inference provider | Transient allowlist versus terminal install failure | Per-attempt command artifacts; restricted paths add a terminal skip artifact | Existing bounded paths; no deterministic install retry |

test/e2e/support/base-image-publication-workflow-boundary.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ function gateSteps(value: MutableWorkflow): MutableStep[] {
6060
);
6161
}
6262

63+
function gateStep(value: MutableWorkflow, name: string): MutableStep {
64+
return required(
65+
gateSteps(value).find((step) => step.name === name),
66+
`base-image-publication test fixture is missing step ${name}`,
67+
);
68+
}
69+
6370
function runClassifier(environment: {
6471
checkoutSha: string;
6572
eventName: string;
@@ -181,12 +188,17 @@ describe("base-image publication workflow boundary (#7372)", () => {
181188
},
182189
],
183190
[
184-
"contract download pin",
185-
(value) => (gateSteps(value)[4].uses = "actions/download-artifact@v8"),
191+
"contract download command",
192+
(value) =>
193+
(gateStep(value, "Download immutable Deep Agents Code base contract").run =
194+
"node unreviewed.mts"),
186195
],
187196
[
188197
"contract run binding",
189-
(value) => (gateSteps(value)[4].with!["run-id"] = "${{ github.run_id }}"),
198+
(value) =>
199+
(gateStep(value, "Download immutable Deep Agents Code base contract").env![
200+
"PUBLICATION_RUN_ID"
201+
] = "${{ github.run_id }}"),
190202
],
191203
[
192204
"contract validation",

test/e2e/support/e2e-collaborator-permission-retry.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,9 @@ const AUTHORIZATION_STEPS: AuthorizationStep[] = [
3636
name: "Authorize release qualification waiver",
3737
},
3838
{
39-
deniedMessage: "Launchable image publication requires a repository maintainer or administrator",
40-
mismatchMessage: "Launchable image publication permission response did not match the actor",
41-
name: "Authorize Launchable image publication",
39+
deniedMessage: "Launchable E2E requires a repository maintainer or administrator",
40+
mismatchMessage: "Launchable E2E permission response did not match the actor",
41+
name: "Authorize Launchable E2E maintainer dispatch",
4242
},
4343
];
4444

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { createHash } from "node:crypto";
5+
import fs from "node:fs";
6+
import os from "node:os";
7+
import path from "node:path";
8+
9+
import { describe, expect, it, vi } from "vitest";
10+
11+
import {
12+
bindExactArtifact,
13+
downloadBoundArtifact,
14+
exactArtifactName,
15+
materializeContractArchive,
16+
type BoundArtifactIdentity,
17+
type ExactArtifactExpectation,
18+
} from "../../../tools/e2e/exact-artifact-download.mts";
19+
import { validateDcodeBaseImageContract } from "../../../tools/e2e/dcode-base-image-contract.mts";
20+
import { artifactZip } from "../../helpers/artifact-zip";
21+
22+
const EXPECTED: ExactArtifactExpectation = {
23+
headSha: "a".repeat(40),
24+
runAttempt: 2,
25+
runId: 7001,
26+
};
27+
28+
function archive(contents = "{}\n"): Buffer {
29+
return artifactZip([{ name: "contract.json", contents }]);
30+
}
31+
32+
function metadata(bytes: Buffer, overrides: Record<string, unknown> = {}): unknown {
33+
const id = 9001;
34+
return {
35+
total_count: 1,
36+
artifacts: [
37+
{
38+
id,
39+
name: exactArtifactName(EXPECTED),
40+
size_in_bytes: bytes.length,
41+
expired: false,
42+
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
43+
archive_download_url: `https://api.github.com/repos/NVIDIA/NemoClaw/actions/artifacts/${id}/zip`,
44+
workflow_run: { id: EXPECTED.runId, head_sha: EXPECTED.headSha },
45+
...overrides,
46+
},
47+
],
48+
};
49+
}
50+
51+
function identity(bytes = archive()): BoundArtifactIdentity {
52+
return bindExactArtifact(metadata(bytes), EXPECTED);
53+
}
54+
55+
function response(bytes: Buffer): Response {
56+
return new Response(new Uint8Array(bytes), {
57+
status: 200,
58+
headers: { "content-length": String(bytes.length) },
59+
});
60+
}
61+
62+
function parseArtifactReadEvidence(message: string): Record<string, string> {
63+
const [operation, ...fields] = message.trim().split(/\s+/u);
64+
return {
65+
operation,
66+
...Object.fromEntries(fields.map((field) => field.split("=", 2))),
67+
};
68+
}
69+
70+
describe("exact artifact download (#9340)", () => {
71+
it("binds immutable identity before the content read", () => {
72+
const bytes = archive();
73+
expect(bindExactArtifact(metadata(bytes), EXPECTED)).toEqual({
74+
...EXPECTED,
75+
archivePath: "/repos/NVIDIA/NemoClaw/actions/artifacts/9001/zip",
76+
digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
77+
id: 9001,
78+
name: exactArtifactName(EXPECTED),
79+
size: bytes.length,
80+
});
81+
});
82+
83+
it.each([
84+
["expired", { expired: true }, "non-expired"],
85+
["artifact id URL", { id: 9002 }, "archive URL does not match artifact id"],
86+
["name", { name: "another-artifact" }, "missing or ambiguous"],
87+
["digest", { digest: "sha256:invalid" }, "digest is invalid"],
88+
[
89+
"run",
90+
{ workflow_run: { id: 7002, head_sha: EXPECTED.headSha } },
91+
"producer run does not match",
92+
],
93+
[
94+
"head",
95+
{ workflow_run: { id: EXPECTED.runId, head_sha: "b".repeat(40) } },
96+
"producer head does not match",
97+
],
98+
[
99+
"archive URL",
100+
{ archive_download_url: "https://example.com/artifact.zip" },
101+
"archive URL does not match artifact id",
102+
],
103+
])("rejects %s identity drift", (_field, overrides, message) => {
104+
expect(() => bindExactArtifact(metadata(archive(), overrides), EXPECTED)).toThrow(message);
105+
});
106+
107+
it("rejects ambiguous artifact metadata", () => {
108+
const value = metadata(archive()) as { artifacts: unknown[]; total_count: number };
109+
value.artifacts.push(value.artifacts[0]);
110+
value.total_count = 2;
111+
expect(() => bindExactArtifact(value, EXPECTED)).toThrow("missing or ambiguous");
112+
});
113+
114+
it("retries one transient response against the same artifact and honors Retry-After", async () => {
115+
const bytes = archive();
116+
const fetchImpl = vi
117+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
118+
.mockResolvedValueOnce(
119+
new Response("sensitive upstream body", { status: 503, headers: { "retry-after": "2" } }),
120+
)
121+
.mockResolvedValueOnce(response(bytes));
122+
const sleep = vi.fn<(milliseconds: number) => Promise<void>>().mockResolvedValue();
123+
const log = vi.fn<(message: string) => void>();
124+
125+
await expect(
126+
downloadBoundArtifact(identity(bytes), "secret-token", { fetchImpl, log, sleep }),
127+
).resolves.toEqual(bytes);
128+
expect(fetchImpl).toHaveBeenCalledTimes(2);
129+
expect(fetchImpl.mock.calls.map(([url]) => url)).toEqual([
130+
"https://api.github.com/repos/NVIDIA/NemoClaw/actions/artifacts/9001/zip",
131+
"https://api.github.com/repos/NVIDIA/NemoClaw/actions/artifacts/9001/zip",
132+
]);
133+
expect(sleep).toHaveBeenCalledWith(2000);
134+
const evidence = log.mock.calls.map(([message]) => parseArtifactReadEvidence(message));
135+
expect(evidence).toEqual([
136+
expect.objectContaining({
137+
operation: "artifact-content-read",
138+
attempt: "1",
139+
status: "503",
140+
outcome: "retry",
141+
}),
142+
expect.objectContaining({
143+
operation: "artifact-content-read",
144+
attempt: "2",
145+
outcome: "passed-after-retry",
146+
}),
147+
]);
148+
expect(log.mock.calls.flat().join("\n")).not.toMatch(/secret|upstream body|Authorization/u);
149+
});
150+
151+
it("fails after three transient responses without changing identity", async () => {
152+
const fetchImpl = vi
153+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
154+
.mockResolvedValue(new Response(null, { status: 500 }));
155+
const sleep = vi.fn<(milliseconds: number) => Promise<void>>().mockResolvedValue();
156+
await expect(downloadBoundArtifact(identity(), "token", { fetchImpl, sleep })).rejects.toThrow(
157+
"HTTP 500",
158+
);
159+
expect(fetchImpl).toHaveBeenCalledTimes(3);
160+
expect(sleep).toHaveBeenCalledTimes(2);
161+
});
162+
163+
it.each([408, 429])("retries transient HTTP %i", async (status) => {
164+
const bytes = archive();
165+
const fetchImpl = vi
166+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
167+
.mockResolvedValueOnce(new Response(null, { status }))
168+
.mockResolvedValueOnce(response(bytes));
169+
const sleep = vi.fn<(milliseconds: number) => Promise<void>>().mockResolvedValue();
170+
await expect(
171+
downloadBoundArtifact(identity(bytes), "token", { fetchImpl, sleep }),
172+
).resolves.toEqual(bytes);
173+
expect(fetchImpl).toHaveBeenCalledTimes(2);
174+
});
175+
176+
it("retries a transport failure", async () => {
177+
const bytes = archive();
178+
const fetchImpl = vi
179+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
180+
.mockRejectedValueOnce(new Error("connection reset"))
181+
.mockResolvedValueOnce(response(bytes));
182+
const sleep = vi.fn<(milliseconds: number) => Promise<void>>().mockResolvedValue();
183+
await expect(
184+
downloadBoundArtifact(identity(bytes), "token", { fetchImpl, sleep }),
185+
).resolves.toEqual(bytes);
186+
expect(fetchImpl).toHaveBeenCalledTimes(2);
187+
});
188+
189+
it.each([401, 403, 404, 410, 422])("does not retry terminal HTTP %i", async (status) => {
190+
const fetchImpl = vi
191+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
192+
.mockResolvedValue(new Response(null, { status }));
193+
await expect(downloadBoundArtifact(identity(), "token", { fetchImpl })).rejects.toThrow(
194+
`HTTP ${status}`,
195+
);
196+
expect(fetchImpl).toHaveBeenCalledTimes(1);
197+
});
198+
199+
it("does not retry digest mismatch", async () => {
200+
const expectedBytes = archive();
201+
const actualBytes = Buffer.from(expectedBytes);
202+
actualBytes[0] ^= 0xff;
203+
const fetchImpl = vi
204+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
205+
.mockResolvedValue(response(actualBytes));
206+
await expect(
207+
downloadBoundArtifact(identity(expectedBytes), "token", { fetchImpl }),
208+
).rejects.toThrow("digest");
209+
expect(fetchImpl).toHaveBeenCalledTimes(1);
210+
});
211+
212+
it("does not retry a content-length mismatch", async () => {
213+
const bytes = archive();
214+
const fetchImpl = vi
215+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
216+
.mockResolvedValue(
217+
new Response(new Uint8Array(bytes), {
218+
headers: { "content-length": String(bytes.length + 1) },
219+
}),
220+
);
221+
await expect(downloadBoundArtifact(identity(bytes), "token", { fetchImpl })).rejects.toThrow(
222+
"content length",
223+
);
224+
expect(fetchImpl).toHaveBeenCalledTimes(1);
225+
});
226+
227+
it("stops an unbounded response stream before retaining oversized content", async () => {
228+
const bytes = archive();
229+
const fetchImpl = vi
230+
.fn<(input: string, init: RequestInit) => Promise<Response>>()
231+
.mockResolvedValue(new Response(new Uint8Array(Buffer.concat([bytes, Buffer.from("x")]))));
232+
await expect(downloadBoundArtifact(identity(bytes), "token", { fetchImpl })).rejects.toThrow(
233+
"content size",
234+
);
235+
expect(fetchImpl).toHaveBeenCalledTimes(1);
236+
});
237+
238+
it.each([
239+
[{ attempts: 0 }, "attempts must be between"],
240+
[{ attempts: 4 }, "attempts must be between"],
241+
[{ timeoutMs: 0 }, "timeout must be between"],
242+
[{ timeoutMs: 20_001 }, "timeout must be between"],
243+
])("rejects invalid download bounds without a request", async (options, message) => {
244+
const fetchImpl = vi.fn<(input: string, init: RequestInit) => Promise<Response>>();
245+
await expect(
246+
downloadBoundArtifact(identity(), "token", { ...options, fetchImpl }),
247+
).rejects.toThrow(message);
248+
expect(fetchImpl).not.toHaveBeenCalled();
249+
});
250+
251+
it("rejects a multiline token without exposing or sending it", async () => {
252+
const fetchImpl = vi.fn<(input: string, init: RequestInit) => Promise<Response>>();
253+
const token = "secret-token\nsecond-line";
254+
const failure = downloadBoundArtifact(identity(), token, { fetchImpl });
255+
await expect(failure).rejects.toThrow("single-line value");
256+
await expect(failure).rejects.not.toThrow(/secret-token|second-line/u);
257+
expect(fetchImpl).not.toHaveBeenCalled();
258+
});
259+
260+
it("rejects malformed archives before writing a contract", () => {
261+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-artifact-"));
262+
try {
263+
expect(() => materializeContractArchive(Buffer.from("not a zip"), directory)).toThrow(
264+
"exactly one contract.json",
265+
);
266+
expect(fs.readdirSync(directory)).toEqual([]);
267+
} finally {
268+
fs.rmSync(directory, { force: true, recursive: true });
269+
}
270+
});
271+
272+
it("leaves contract semantics to the existing fail-closed validator", () => {
273+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-artifact-"));
274+
try {
275+
const contractPath = materializeContractArchive(archive(), directory);
276+
const value = JSON.parse(fs.readFileSync(contractPath, "utf8")) as unknown;
277+
expect(() => validateDcodeBaseImageContract(value, EXPECTED)).toThrow();
278+
} finally {
279+
fs.rmSync(directory, { force: true, recursive: true });
280+
}
281+
});
282+
});

0 commit comments

Comments
 (0)