diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/README.md b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/README.md new file mode 100644 index 0000000..cbf2095 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/README.md @@ -0,0 +1,243 @@ +# NemoClaw Skill Install — AFTER_DOWNLOAD audit log + +## Overview + +[NVIDIA NemoClaw](https://docs.nvidia.com/nemoclaw/user-guide/openclaw/home) is NVIDIA's +open-source reference stack for running AI agents more safely — sandboxed execution, managed +inference, and policy/lifecycle operations. Its default agent runtime, **OpenClaw**, loads +capabilities called **Skills** from `~/.agents/skills//` inside the sandbox, following the +open [agentskills.io](https://agentskills.io/specification) specification: a directory whose only +required file is `SKILL.md` with YAML frontmatter (`name`, `description`). + +JFrog's **Agent Skills Registry** (part of the AI Catalog) publishes and serves skills in exactly +that same format — a `jf skills publish` bundle is a `SKILL.md`-rooted directory, scanned by Xray +and optionally signed with cryptographic evidence on the way in. JFrog and NVIDIA have announced +Artifactory as a registry for NemoClaw's agent skills and models as part of the AI-Q Blueprint / +NemoClaw trust-layer partnership, but neither vendor's public documentation describes the exact +client-side call NemoClaw's runtime makes to consume it. + +This sample bridges the two using a path that **is** documented on both sides, even though the +combination isn't spelled out anywhere as "the" integration: + +- `jf skills install --repo --path ` is a supported, harness-independent install + target (`--path` is the documented fallback for any client that isn't `claude-code`/`cursor`/etc.) + — it installs the bundle to `/`. +- Pointed at `--path ~/.agents/skills`, the installed bundle lands exactly where OpenClaw scans for + skills, and — because both sides already speak `SKILL.md` — needs no format conversion. + +So: an operator (or, for the full walkthrough below, the NemoClaw sandbox itself via +`nemoclaw exec`) runs `jf skills install` against an Artifactory skills repo, targeting the +sandbox's own skills directory. That download is a real Artifactory download like any other — +`AFTER_DOWNLOAD` fires on it — and this worker turns it into a provenance/audit record. + +**Why this is worth auditing, specifically:** the JFrog AI Catalog's evidence check is opt-in and +**bypassable** — if a skill has no signed evidence, `jf skills install` fails closed by default, but +an operator can force it through with `JFROG_SKILLS_DISABLE_QUIET_FAILURE=true`. Once that happens, +nothing records that a NemoClaw sandbox loaded code without the trust check — the CLI's warning is +the only trace, and it isn't persisted anywhere. This worker closes that gap: every skill install +served to a NemoClaw identity gets a durable record of what was installed and what its scan/signing +state was at download time. + +**`AFTER_DOWNLOAD` cannot block anything** — the artifact is already served by the time the event +fires, and Xray's own download gating (`SCAN_IN_PROGRESS` / policy-blocked → HTTP 403) has already +run by then too. This sample is intentionally audit-only. Blocking unsigned/unscanned skills is a +repository-level Xray/curation policy concern, out of scope here. + +## What the worker does + +On every download from the configured skills repo(s): + +1. Parses the skill **slug** and **version** out of `metadata.repoPath.path` (skills archives are + stored as `//-.zip`). +2. Reads `userContext.id` — the Artifactory identity (username or access-token subject) that + authenticated the request — and records it as the download's attributed identity. +3. Calls back into Artifactory (`GET /artifactory/api/skills//xrayStatus?path=...`, via + `context.clients.platformHttp`) to attach the skill's current scan/evidence status to the + record, so the audit trail captures *whether this install was clean and signed*, not just that + it happened. +4. POSTs a structured record to an external audit sink: + +```json +{ + "source": "nemoclaw-skill-install", + "slug": "cuopt-routing-skill", + "version": "1.2.0", + "repoKey": "nemoclaw-skills-local", + "repoType": "REPO_TYPE_LOCAL", + "registryIdentity": "nemoclaw-sandbox-my-assistant", + "isToken": true, + "xrayStatus": "", + "downloadedAt": "2026-09-25T12:00:00.000Z" +} +``` + +5. Never throws — failures are logged and reflected only in the returned `message`. + +> **Confirm before relying on step 3 for anything beyond a demo:** the `xrayStatus` endpoint's +> documented response distinguishes `SCAN_IN_PROGRESS` and policy-blocked states; whether it also +> surfaces evidence/signing state specifically (vs. that living under a separate `jf evd`/evidence +> API) needs to be checked against a real response before the worker leans on it for the +> "was this signed" claim. Treat that field as best-effort enrichment, not a verified security +> control, until confirmed. + +## Files + +| File | Purpose | +|---|---| +| `worker.ts` | Worker logic | +| `types.ts` | `AFTER_DOWNLOAD` payload types | +| `worker.spec.ts` | Unit tests (success, sink non-200, thrown error) | +| `manifest.json` | Worker registration — action, filter criteria, secrets, properties | +| `package.json`, `tsconfig.json` | Build/test config | + +## Prerequisites + +- A JFrog Platform instance with Workers enabled and an **Agent Skills / AI Catalog skills repo** + (`packageType=skills`, local) — provisioned via the JFrog AI Catalog / Agent Guard flow, or an + existing one your org already uses. Update `filterCriteria.artifactFilterCriteria.repoKeys` in + `manifest.json` to match your actual repo key. +- `jf` CLI configured against that instance (`jf c add` / `jf login`), with a skill already + published to the repo above (`jf skills publish --repo `) so there's something + to install. +- An HTTP endpoint to act as the audit sink for testing (e.g. [webhook.site](https://webhook.site), + a Coralogix/Splunk HTTP intake, or a throwaway local listener). +- For the full walkthrough (§ "Testing E2E with a real NemoClaw sandbox" below): a local NemoClaw + install — no GPU/DGX required, just 4 vCPU / 8 GB RAM / 20 GB disk on Linux, macOS, or Windows + WSL2 (`curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash`, then `nemoclaw onboard`). + **On macOS specifically, the container runtime must be Docker Desktop or Colima** — confirmed by + running the installer: NemoClaw's own host-qualification check + (`src/lib/readiness/platform-qualification.ts`) only recognizes `runtime === "docker-desktop"` or + `"colima"` on macOS, with no generic-Docker fallback (unlike its WSL path, which does accept a + plain `docker` runtime). A Rancher Desktop-backed Docker daemon is fully functional but fails + onboarding's preflight with `host.platform.unsupported` regardless. + +## Configuration + +The worker reads its audit sink URL from a **Worker property** (`context.properties`), and the +bearer token for the sink from a **Worker secret** (`context.secrets`): + +```json +"properties": { + "auditSinkUrl": "https://" +} +``` + +Register the secret before deploying: + +```bash +jf worker add-secret auditSinkBearerToken +``` + +## Running the unit tests + +```bash +npm install +npm test +``` + +## Testing E2E + +Three levels, fastest to full real-world flow. + +### 1. Sandbox test-run (no live repo, no NemoClaw) + +```bash +jf worker test-run @sample-payload.json +``` + +Point `auditSinkUrl` at something like webhook.site first so you can confirm the POST lands with +the expected fields. This is the fastest way to validate the worker before deploying it. + +### 2. Real deploy + real download (Artifactory + `jf skills`, no NemoClaw) + +1. Set `"enabled": true` in `manifest.json` and `jf worker deploy`. +2. Trigger a real download by installing the published test skill to an arbitrary directory: + ```bash + jf skills install "" --server-id "" --repo "" --path /tmp/skills-test --quiet + ``` +3. Confirm the audit sink received the POST, and cross-check the worker's execution log + (`jf worker` UI → the worker → **Logs**) for `NemoClaw skill install successfully audited`. + +### 3. Full walkthrough with a real NemoClaw sandbox + +This exercises the actual scenario the worker is built for: a NemoClaw sandbox pulling a skill from +Artifactory into its own skills directory. + +1. **Install and onboard NemoClaw** (see *Prerequisites*): `nemoclaw onboard --name my-assistant`, + picking any inference provider — it doesn't affect this flow. +2. **Provision a dedicated Artifactory identity for this sandbox** (an access token used only by + it), so downloads it makes are distinguishable from anyone else's `jf skills install` in the + audit trail. Note the exact identity string `jf` reports for that token — confirm empirically + (do one real install with this token and read the actual `registryIdentity` value back from the + audit sink) rather than assuming a format. +3. **Install the `jf` CLI inside the sandbox.** It isn't preinstalled — NemoClaw's documented + sandbox toolchain is Node.js, npm, Python, and a container runtime, nothing JFrog-specific. Its + install script pulls from a *different* host than Artifactory itself + (`install-cli.jfrog.io`), so it needs its own network-policy preset first (same deny-by-default + reasoning as the Artifactory host below): + ```bash + nemoclaw my-assistant policy add --from-file ./jfrog-cli-install-preset.yaml \ + --trusted-private-host install-cli.jfrog.io --yes + nemoclaw my-assistant exec -- sh -c "curl -fL https://install-cli.jfrog.io | sh" + ``` +4. **Make the Artifactory identity reachable from inside the sandbox.** Reaching your Artifactory + host requires its own custom policy preset (the built-in `npm_registry` preset only covers the + public npm registry) — see the network-policy documentation for the preset file format. + Configure `jf` with the token from step 2: + ```bash + nemoclaw my-assistant policy add --from-file ./artifactory-preset.yaml \ + --trusted-private-host --yes + nemoclaw my-assistant exec -- jf c add nemoclaw-target --url https:// \ + --access-token "" --interactive=false + ``` +5. **Trigger the install from inside the sandbox**, targeting OpenClaw's own skills directory: + ```bash + nemoclaw my-assistant exec -- jf skills install "" \ + --server-id nemoclaw-target --repo "" --path ~/.agents/skills --quiet + ``` +6. **Verify.** The audit sink should receive a POST whose `registryIdentity` matches the token from + step 2, `slug`/`version` match what was installed, and (per *Confirm before relying on step 3* + above) whatever `xrayStatus` reported. Restart the sandbox's agent (`nemoclaw launch my-assistant`) + to confirm OpenClaw picked up the new skill under `~/.agents/skills//`. + +## Response messages + +- Success: `NemoClaw skill install successfully audited` +- `auditSinkUrl` property not set: `Worker property 'auditSinkUrl' is not set; skipping audit` +- Sink returned non-200: `Failed to audit NemoClaw skill install` +- Network/other error: `Failed to audit NemoClaw skill install` + +## Limitations + +- No blocking/remediation — `AFTER_DOWNLOAD` fires after the file is already served; Xray's own + download-gating has already run by this point regardless of this worker. +- **The `jf skills install --path ~/.agents/skills` bridge is not an officially documented NemoClaw + integration.** It's a combination of two independently-documented facts (the `agentskills.io` + format match and the `--path` install target) rather than a supported, named feature of either + product. Treat it as a working demonstration, not a vendor-endorsed pattern, and re-confirm both + sides' docs before relying on it beyond this sample. +- `registryIdentity` attribution is per-credential, not automatically per-agent-session — if every + NemoClaw sandbox shares one Artifactory token, you can tell "a NemoClaw sandbox did this" but not + which one. For per-sandbox attribution, mint one token per `nemoclaw ` instance. +- The custom network-policy presets and the `jf` install/config steps inside the sandbox + (walkthrough steps 3-4) are the least-verified parts of this sample — confirm the preset schema + and binary path against your own NemoClaw version's `policy add --dry-run` output before + scripting them. In particular, whether `install-cli.jfrog.io`'s install script redirects to a + further download host (e.g. a release CDN) that also needs allowlisting isn't confirmed here — + check the `policy add --dry-run` output for any additional blocked hosts before assuming one + preset covers the whole install. +- **The NemoClaw installer's default `lkg` ("last known good") ref can lag fixes on its `main` + branch.** Confirmed directly: the `lkg` tag active at the time of writing rejects any Docker + context not literally named `default` (`validate_installer_docker_target_before_host_changes` in + `scripts/install.sh`), even a fully working one — a check already relaxed on `main` (and in + tagged release `v0.0.129`) to inspect the active context's actual socket instead of its name. If + `curl -fsSL https://www.nvidia.com/nemoclaw.sh | bash` fails with *"The Docker context does not + select the local default target"* on a non-`default`-named but working context, either switch the + active context to a genuinely working `default` (Docker Desktop), or pin the installer to a newer + release with `NEMOCLAW_INSTALL_TAG=v0.0.129` (or later). + +## Cleanup + +```bash +jf worker rm "nemoclaw-skill-install-audit" +``` diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/manifest.json b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/manifest.json new file mode 100644 index 0000000..54ba53d --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "nemoclaw-skill-install-audit", + "description": "Audits AFTER_DOWNLOAD events for skill archives pulled from an Artifactory Agent Skills repo, recording the registry identity and Xray/evidence status of every install so downloads served to a NemoClaw sandbox get a durable provenance trail.", + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "nemoclaw-skills-local" + ] + } + }, + "secrets": {}, + "properties": { + "auditSinkUrl": "https://" + }, + "sourceCodePath": "./worker.ts", + "action": "AFTER_DOWNLOAD", + "enabled": false, + "debug": true +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/package.json b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/package.json new file mode 100644 index 0000000..9d99bfb --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/package.json @@ -0,0 +1,40 @@ +{ + "name": "nemoclaw-skill-install-audit", + "description": "Run a script on AFTER_DOWNLOAD", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"nemoclaw-skill-install-audit\"", + "test": "jest" + }, + "license": "ISC", + "devDependencies": { + "jfrog-workers": "^0.19.0", + "@golevelup/ts-jest": "^0.4.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-jasmine2": "^29.7.0", + "ts-jest": "^29.1.2" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "js" + ], + "rootDir": ".", + "testEnvironment": "node", + "clearMocks": true, + "maxConcurrency": 1, + "testRegex": "\\.spec\\.ts$", + "moduleDirectories": ["node_modules"], + "collectCoverageFrom": [ + "**/*.ts" + ], + "coverageDirectory": "../coverage", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testRunner": "jest-jasmine2", + "verbose": true + } +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/sample-payload.json b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/sample-payload.json new file mode 100644 index 0000000..69ece83 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/sample-payload.json @@ -0,0 +1,17 @@ +{ + "metadata": { + "repoPath": { + "key": "nemoclaw-skills-local", + "path": "cuopt-routing-skill/1.2.0/cuopt-routing-skill-1.2.0.zip", + "id": "nemoclaw-skills-local:cuopt-routing-skill/1.2.0/cuopt-routing-skill-1.2.0.zip", + "isRoot": false, + "isFolder": false + }, + "repoType": 1 + }, + "userContext": { + "id": "nemoclaw-sandbox-my-assistant", + "isToken": true, + "realm": "" + } +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/tsconfig.json b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/tsconfig.json new file mode 100644 index 0000000..8005cfb --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "target": "es2017", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "allowJs": true + }, + "include": [ + "**/*.ts", + "node_modules/@types/**/*.d.ts" + ] +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/types.ts b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/types.ts new file mode 100644 index 0000000..5646037 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/types.ts @@ -0,0 +1,93 @@ + +export interface AfterDownloadRequest { + /** Various immutable download metadata */ + metadata: + | DownloadMetadata + | undefined; + /** The immutable request headers */ + headers: { [key: string]: Header }; + /** The user context which sends the request */ + userContext: UserContext | undefined; +} + +export interface AfterDownloadResponse { + /** Message to print to the log, in case of an error it will be printed as a warning */ + message: string; +} + +export interface DownloadMetadata { + /** The repoPath object of the request */ + repoPath: + | RepoPath + | undefined; + /** The original repo path in case a virtual repo is involved */ + originalRepoPath: + | RepoPath + | undefined; + /** The file name from path */ + name: string; + /** Is it a head request */ + headOnly: boolean; + /** Is it a checksum request */ + checksum: boolean; + /** Is it a recursive request */ + recursive: boolean; + /** When a modification has occurred */ + modificationTime: number; + /** Is it a directory request */ + directoryRequest: boolean; + /** Is it a metadata request */ + metadata: boolean; + /** Last modification time that occurred */ + lastModified: number; + /** If a modification happened since the last modification time */ + ifModifiedSince: number; + /** The url that points to artifactory */ + servletContextUrl: string; + /** The request URI */ + uri: string; + /** The client address */ + clientAddress: string; + /** The resource path of the requested zip */ + zipResourcePath: string; + /** Is the request a zip resource request */ + zipResourceRequest: boolean; + /** should replace the head request with get */ + replaceHeadRequestWithGet: boolean; + /** Repository type */ + repoType: RepoType; +} + +export interface Header { + value: string[]; +} + +export interface UserContext { + /** The username or subject */ + id: string; + /** Is the context an accessToken */ + isToken: boolean; + /** The realm of the user */ + realm: string; +} + +export enum RepoType { + REPO_TYPE_UNSPECIFIED = 0, + REPO_TYPE_LOCAL = 1, + REPO_TYPE_REMOTE = 2, + REPO_TYPE_FEDERATED = 3, + UNRECOGNIZED = -1, +} + +export interface RepoPath { + /** The repo key */ + key: string; + /** The path itself */ + path: string; + /** The key:path combination */ + id: string; + /** Is the path the root */ + isRoot: boolean; + /** Is the path a folder */ + isFolder: boolean; +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/worker.spec.ts b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/worker.spec.ts new file mode 100644 index 0000000..f5e1c55 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/worker.spec.ts @@ -0,0 +1,103 @@ +import { PlatformContext, PlatformClients, PlatformHttpClient } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AfterDownloadRequest, RepoType } from './types'; +import runWorker from './worker'; + +describe('nemoclaw-skill-install-audit tests', () => { + let context: DeepMocked; + let request: DeepMocked; + let postMock: jest.Mock; + let getPropertyMock: jest.Mock; + let platformHttpGetMock: jest.Mock; + + beforeEach(() => { + postMock = jest.fn().mockResolvedValue({ status: 200 }); + getPropertyMock = jest.fn((key: string) => ({ auditSinkUrl: 'https://audit-sink.example.com' })[key]); + platformHttpGetMock = jest.fn().mockResolvedValue({ data: { status: 'PASSED' } }); + context = createMock({ + clients: createMock({ + axios: { post: postMock } as any, + platformHttp: createMock({ get: platformHttpGetMock }), + }), + secrets: { get: jest.fn().mockReturnValue('test-token') } as any, + properties: { get: getPropertyMock } as any, + }); + request = createMock({ + metadata: { + repoPath: { + key: 'nemoclaw-skills-local', + path: 'cuopt-routing-skill/1.2.0/cuopt-routing-skill-1.2.0.zip', + id: 'nemoclaw-skills-local:cuopt-routing-skill/1.2.0/cuopt-routing-skill-1.2.0.zip', + isRoot: false, + isFolder: false, + }, + repoType: RepoType.REPO_TYPE_LOCAL, + } as any, + userContext: { + id: 'nemoclaw-sandbox-my-assistant', + isToken: true, + realm: '', + }, + }); + }); + + it('audits the download with parsed slug/version, identity, and Xray status', async () => { + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ message: 'NemoClaw skill install successfully audited' }), + ); + expect(platformHttpGetMock).toHaveBeenCalledWith( + '/artifactory/api/skills/nemoclaw-skills-local/xrayStatus?path=cuopt-routing-skill%2F1.2.0%2Fcuopt-routing-skill-1.2.0.zip', + ); + expect(postMock).toHaveBeenCalledWith( + 'https://audit-sink.example.com', + expect.objectContaining({ + source: 'nemoclaw-skill-install', + slug: 'cuopt-routing-skill', + version: '1.2.0', + repoKey: 'nemoclaw-skills-local', + repoType: 'REPO_TYPE_LOCAL', + registryIdentity: 'nemoclaw-sandbox-my-assistant', + isToken: true, + xrayStatus: 'PASSED', + }), + expect.objectContaining({ headers: { Authorization: 'Bearer test-token' } }), + ); + }); + + it('skips auditing when the auditSinkUrl property is not set', async () => { + getPropertyMock.mockImplementation(() => undefined); + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ + message: "Worker property 'auditSinkUrl' is not set; skipping audit", + }), + ); + expect(postMock).not.toHaveBeenCalled(); + expect(platformHttpGetMock).not.toHaveBeenCalled(); + }); + + it('records an UNKNOWN Xray status when the lookup fails, but still audits', async () => { + platformHttpGetMock.mockRejectedValueOnce(new Error('xray unreachable')); + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ message: 'NemoClaw skill install successfully audited' }), + ); + expect(postMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ xrayStatus: 'UNKNOWN' }), + expect.anything(), + ); + }); + + it('reports failure when the audit sink does not return 200', async () => { + postMock.mockResolvedValueOnce({ status: 500 }); + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ message: 'Failed to audit NemoClaw skill install' }), + ); + }); + + it('reports failure when the audit sink call throws', async () => { + postMock.mockRejectedValueOnce(new Error('network error')); + await expect(runWorker(context, request)).resolves.toEqual( + expect.objectContaining({ message: 'Failed to audit NemoClaw skill install' }), + ); + }); +}); diff --git a/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/worker.ts b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/worker.ts new file mode 100644 index 0000000..7fd6831 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/nemoclaw-skill-install-audit/worker.ts @@ -0,0 +1,81 @@ +import { PlatformContext } from 'jfrog-workers'; +import { AfterDownloadRequest, AfterDownloadResponse, RepoType } from './types'; +import { AxiosRequestConfig } from 'axios'; + +export default async (context: PlatformContext, data: AfterDownloadRequest): Promise => { + // MODIFY TO FIT YOUR NEEDS + const AUDIT_SINK_URL_PROPERTY = 'auditSinkUrl'; + const SECRET_NAME = 'auditSinkBearerToken'; + + let message = 'NemoClaw skill install successfully audited'; + try { + const auditSinkUrl = context.properties.get(AUDIT_SINK_URL_PROPERTY); + if (!auditSinkUrl) { + return { message: `Worker property '${AUDIT_SINK_URL_PROPERTY}' is not set; skipping audit` }; + } + + const repoPath = data.metadata?.repoPath; + const [slug, version] = parseSkillPath(repoPath?.path); + const xrayStatus = await fetchXrayStatus(context, repoPath?.key, repoPath?.path); + + const auditRecord = { + source: 'nemoclaw-skill-install', + slug, + version, + repoKey: repoPath?.key, + repoType: data.metadata?.repoType !== undefined ? RepoType[data.metadata.repoType] : 'UNKNOWN', + registryIdentity: data.userContext?.id, + isToken: data.userContext?.isToken, + xrayStatus, + downloadedAt: new Date().toISOString(), + }; + + const res = await context.clients.axios.post(auditSinkUrl, auditRecord, { + headers: { + Authorization: `Bearer ${context.secrets.get(SECRET_NAME)}`, + }, + }); + + if (res.status === 200) { + console.log(`Audited NemoClaw skill install of '${repoPath?.id}' by '${auditRecord.registryIdentity}'`); + } else { + console.warn(`Failed to audit NemoClaw skill install. Status code: ${res.status}`); + message = 'Failed to audit NemoClaw skill install'; + } + } catch (error) { + console.error(`Failed to audit NemoClaw skill install, caused by: ${(error as Error).message}`); + message = 'Failed to audit NemoClaw skill install'; + } + + return { + message, + }; +}; + +// Skill archives published via `jf skills publish` are stored as //-.zip. +function parseSkillPath(path: string | undefined): [string | undefined, string | undefined] { + const parts = path?.split('/'); + if (!parts || parts.length < 2) { + return [undefined, undefined]; + } + return [parts[0], parts[1]]; +} + +async function fetchXrayStatus( + context: PlatformContext, + repoKey: string | undefined, + path: string | undefined, +): Promise { + if (!repoKey || !path) { + return 'UNKNOWN'; + } + try { + const res = await context.clients.platformHttp.get( + `/artifactory/api/skills/${repoKey}/xrayStatus?path=${encodeURIComponent(path)}`, + ); + return res.data?.status ?? 'UNKNOWN'; + } catch (error) { + console.warn(`Failed to fetch Xray status for '${repoKey}/${path}', caused by: ${(error as Error).message}`); + return 'UNKNOWN'; + } +}