Skip to content

Commit 6bd9e6c

Browse files
Launch the Coana CLI from Socket's servers instead of npm (#411)
* Launch the Coana CLI from Socket's servers instead of npm Coana releases are moving from npm to a private GCS bucket, which the Socket API hands out signed download URLs for. The reachability launcher now runs Coana the way coana-package-manager's "Launching Coana" docs specify: - It looks the version up in the cache that Coana and the Socket CLIs share, through coana-cache.mjs, which is vendored from coana-package-manager and generated there. - On a miss it asks POST /orgs/<org>/coana-cli/artifacts for the cli component, downloads the signed URL while checking its size and sha256, extracts it with tarfile and installs it into the cache. A refused URL is renewed once. - It runs cli-wrapper.mjs with node, passing SOCKET_CLI_API_BASE_URL derived from BASE_API_URL. A version spec that only matches releases from before the move fails with the API's explanation, which CliClient now includes in APIFailure. There is no npm fallback, so the npx and npm install launch paths are gone, along with SOCKET_CLI_COANA_LAUNCHER, and npm and npx are no longer required. The Docker image no longer installs @coana-tech/cli globally. ReachabilityAnalyzer now takes the CliClient instead of the SDK and the token. The tests run the real cache script against a fake cli-wrapper.mjs instead of mocking subprocess. * Inline the single-use extract helper
1 parent a3f641d commit 6bd9e6c

14 files changed

Lines changed: 523 additions & 630 deletions

File tree

‎.github/workflows/python-tests.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ on:
88
branches: [main]
99
paths:
1010
- "socketsecurity/**/*.py"
11+
- "socketsecurity/**/*.mjs"
1112
- "tests/unit/**/*.py"
1213
- "tests/core/**/*.py"
1314
- "pyproject.toml"

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 2.11.0
4+
5+
### Changed: download the Coana CLI from Socket instead of npm
6+
7+
- The Coana CLI is now downloaded from Socket's servers instead of npm, and is now cached in
8+
`$XDG_CACHE_HOME/socket/coana` (`~/.cache/socket/coana` by default).
9+
- The `SOCKET_CLI_COANA_LAUNCHER` environment variable no longer has an effect.
10+
311
## 2.10.12
412

513
### Changed: bump pinned @coana-tech/cli to 15.12.8

‎Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
7575
fi
7676

7777
# Install additional tools
78-
RUN npm install @coana-tech/cli socket -g && \
78+
RUN npm install socket -g && \
7979
gem install bundler && \
8080
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
8181
. ~/.cargo/env && \

‎docs/cli-reference.md‎

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ If you don't want to provide the Socket API Token every time then you can use th
338338
| Parameter | Required | Default | Description |
339339
|:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------|
340340
| `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). |
341-
| `--reach-version` | False | 15.12.8 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. |
341+
| `--reach-version` | False | 15.12.8 | Version of the Coana CLI to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. |
342342
| `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` |
343343
| `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` |
344344
| `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. |
@@ -359,9 +359,7 @@ If you don't want to provide the Socket API Token every time then you can use th
359359
**Reachability Analysis Requirements:**
360360
361361
The Python CLI verifies the following **up front** (before invoking the analysis engine) and exits with code **3** if any are unmet:
362-
- `npm` - Required (verified up front; ships alongside `npx`)
363-
- `npx` - Required to fetch (on first use) and run `@coana-tech/cli` (the analysis engine)
364-
- `node` - Required to run the engine (used directly by the `npm install` fallback)
362+
- `node` - Required to run the analysis engine (the Coana CLI). The engine needs Node.js 20 or later.
365363
- `uv` - Required by the analysis engine
366364
- An **Enterprise** Socket organization plan (any `enterprise*` plan, including Enterprise trials)
367365
@@ -419,12 +417,7 @@ Sample config files:
419417
420418
For CI-specific examples and guidance, see [`ci-cd.md`](ci-cd.md).
421419
422-
The CLI runs a pinned `@coana-tech/cli` version via `npx --yes --force` (the same flags the Socket Node CLI passes for coana); it does **not** auto-update the engine or install it globally. `--yes` skips npx's interactive install prompt so non-interactive/CI runs don't hang. If the `npx` launcher is unavailable or fails before the engine starts, the CLI falls back to `npm install`-ing the pinned version into a temp directory and running it via `node`. Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan.
423-
424-
The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variable:
425-
- `auto` (default when unset) — try `npx` first; fall back to `npm install` + `node` if the launcher fails before the engine starts.
426-
- `npm-install` — skip `npx` entirely and always use the `npm install` + `node` path (useful where `npx` is known-broken).
427-
- `npx` — never fall back; surface the `npx` failure directly.
420+
The CLI runs a pinned version of the Coana CLI, the analysis engine; it does **not** auto-update the engine. It downloads the engine from the Socket API on first use and keeps it in `$XDG_CACHE_HOME/socket/coana/v1` (`~/.cache/socket/coana/v1` when `XDG_CACHE_HOME` is unset). Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan.
428421
429422
#### Advanced Configuration
430423
| Parameter | Required | Default | Description |

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.10.12"
9+
version = "2.11.0"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

‎socketsecurity/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.10.12'
2+
__version__ = '2.11.0'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

‎socketsecurity/config.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1069,7 +1069,7 @@ def create_argument_parser() -> argparse.ArgumentParser:
10691069
"--reach-version",
10701070
dest="reach_version",
10711071
metavar="<version|latest>",
1072-
help="Version of @coana-tech/cli to use. Defaults to the version pinned to this CLI "
1072+
help="Version of the Coana CLI to use. Defaults to the version pinned to this CLI "
10731073
"release; pass 'latest' to always use the newest published version (opt-in "
10741074
"auto-update), or an explicit version (e.g. '1.2.3') to pin it."
10751075
)

‎socketsecurity/core/cli_client.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,26 @@ def request(
5555
return response
5656

5757
except requests.exceptions.RequestException as e:
58-
logger.error(f"API request failed: {str(e)}")
58+
server_message = self._server_error_message(e.response)
59+
detail = f"{e}: {server_message}" if server_message else str(e)
60+
logger.error(f"API request failed: {detail}")
5961
# Carry the status forward. Callers that need to react to a specific
6062
# code -- the GitLab auth fallback to the other token scheme, and
6163
# APIFailure.is_transient_error -- have no other way to recover it
6264
# once the requests exception has been translated.
6365
status_code = e.response.status_code if e.response is not None else None
64-
raise APIFailure(f"Request failed: {str(e)}", status_code=status_code) from e
66+
raise APIFailure(f"Request failed: {detail}", status_code=status_code) from e
67+
68+
@staticmethod
69+
def _server_error_message(response: Optional[requests.Response]) -> Optional[str]:
70+
"""The explanation the Socket API gives in its ``{"error": {"message": ...}}`` body."""
71+
if response is None:
72+
return None
73+
try:
74+
message = response.json()["error"]["message"]
75+
except (ValueError, KeyError, TypeError):
76+
return None
77+
return message if isinstance(message, str) and message else None
6578

6679
def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None:
6780
"""Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget — logs errors but never raises."""
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Generated from coana-package-manager's packages/coana-cache. Don't edit it here.
2+
// See "Launching Coana" in packages/cli/docs/gcs-releases.md there.
3+
4+
// src/cli.ts
5+
import { cp, readdir as readdir2, rename as rename2, rm as rm3 } from "node:fs/promises";
6+
import { join as join3 } from "node:path";
7+
8+
// src/cache.ts
9+
import { mkdir as mkdir2, open, readdir, readFile, rm as rm2, stat, utimes } from "node:fs/promises";
10+
import { homedir } from "node:os";
11+
import { dirname as dirname2, isAbsolute, join as join2, relative } from "node:path";
12+
13+
// ../utils/src/atomic-fs.ts
14+
import { randomUUID } from "node:crypto";
15+
import { existsSync } from "node:fs";
16+
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
17+
import { basename, dirname, join } from "node:path";
18+
async function populateDirAtomically(targetDir, populate) {
19+
if (existsSync(targetDir)) return;
20+
const parentDir = dirname(targetDir);
21+
await mkdir(parentDir, { recursive: true });
22+
const tmpDir = await mkdtemp(join(parentDir, `.${basename(targetDir)}-`));
23+
try {
24+
await populate(tmpDir);
25+
await chmod(tmpDir, 493);
26+
await rename(tmpDir, targetDir).catch((e) => {
27+
if (!existsSync(targetDir)) throw e;
28+
});
29+
} finally {
30+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
31+
});
32+
}
33+
}
34+
async function writeFileAtomically(path, data) {
35+
const tmpPath = `${path}.${randomUUID()}.tmp`;
36+
try {
37+
await writeFile(tmpPath, data);
38+
await rename(tmpPath, path);
39+
} finally {
40+
await rm(tmpPath, { force: true }).catch(() => {
41+
});
42+
}
43+
}
44+
45+
// src/cache.ts
46+
var SHA256 = /^[0-9a-f]{64}$/;
47+
var VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/;
48+
var COMPONENT = /^[A-Za-z][0-9A-Za-z-]*$/;
49+
var TEMP_DIR = /^\.[0-9a-f]{64}-/;
50+
var ABANDONED_TEMP_DIR_MS = 24 * 60 * 6e4;
51+
function cacheRoot() {
52+
const xdgCacheHome = process.env.XDG_CACHE_HOME;
53+
const cacheHome = xdgCacheHome && isAbsolute(xdgCacheHome) ? xdgCacheHome : join2(homedir(), ".cache");
54+
return join2(cacheHome, "socket", "coana", "v1");
55+
}
56+
var OUT_OF_SPACE_CODES = /* @__PURE__ */ new Set(["ENOSPC", "EDQUOT"]);
57+
var NOT_WRITABLE_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "EROFS", ...OUT_OF_SPACE_CODES]);
58+
var CacheNotWritable = class _CacheNotWritable extends Error {
59+
constructor(outOfSpace, cause) {
60+
super(
61+
outOfSpace ? `The disk holding the Coana cache in ${cacheRoot()} is out of space (${cause.message}). Free some up, or set XDG_CACHE_HOME to a directory on another disk.` : `Could not write to the Coana cache in ${cacheRoot()} (${cause.message}). Set XDG_CACHE_HOME to a writable directory.`,
62+
{ cause }
63+
);
64+
}
65+
/** `e` as a {@link CacheNotWritable} when it is such a failure of a path in the cache. */
66+
static from(e) {
67+
if (e instanceof _CacheNotWritable) return e;
68+
if (!(e instanceof Error)) return void 0;
69+
const { code = "", path: source, dest } = e;
70+
if (!NOT_WRITABLE_CODES.has(code)) return void 0;
71+
const outOfSpace = OUT_OF_SPACE_CODES.has(code);
72+
const paths = (outOfSpace && dest !== void 0 ? [dest] : [source, dest]).filter((p) => p !== void 0);
73+
const ofCache = paths.length === 0 ? outOfSpace : paths.some((path) => within(cacheRoot(), path) || within(path, cacheRoot()));
74+
return ofCache ? new _CacheNotWritable(outOfSpace, e) : void 0;
75+
function within(dir, path) {
76+
const fromDir = relative(dir, path);
77+
return !fromDir.startsWith("..") && !isAbsolute(fromDir);
78+
}
79+
}
80+
};
81+
var InvalidCacheKey = class extends Error {
82+
};
83+
async function writing(f) {
84+
try {
85+
return await f();
86+
} catch (e) {
87+
throw CacheNotWritable.from(e) ?? e;
88+
}
89+
}
90+
function objectDir(sha256) {
91+
return join2(cacheRoot(), "objects", sha256);
92+
}
93+
function versionSegment(version) {
94+
if (!VERSION.test(version)) throw new InvalidCacheKey(`Invalid Coana version: ${version}`);
95+
return version;
96+
}
97+
function indexFile(version, component) {
98+
if (!COMPONENT.test(component)) throw new InvalidCacheKey(`Invalid component name: ${component}`);
99+
return join2(cacheRoot(), "versions", versionSegment(version), component);
100+
}
101+
async function lookup(version, component) {
102+
const sha256 = (await readFile(indexFile(version, component), "utf8").catch(() => "")).trim();
103+
if (!SHA256.test(sha256)) return void 0;
104+
const dir = objectDir(sha256);
105+
const isDirectory = await stat(dir).then(
106+
(s) => s.isDirectory(),
107+
() => false
108+
);
109+
return isDirectory ? dir : void 0;
110+
}
111+
async function install(version, component, sha256, populate) {
112+
if (!SHA256.test(sha256)) throw new InvalidCacheKey(`Invalid SHA-256: ${sha256}`);
113+
const index = indexFile(version, component);
114+
const target = objectDir(sha256);
115+
await writing(() => mkdir2(dirname2(index), { recursive: true }));
116+
await writing(
117+
() => populateDirAtomically(target, (dir) => {
118+
void sweepAbandonedTempDirs();
119+
return populate(dir);
120+
})
121+
);
122+
await writing(() => writeFileAtomically(index, sha256));
123+
return target;
124+
}
125+
var sweeps = /* @__PURE__ */ new Map();
126+
function sweepAbandonedTempDirs() {
127+
const objects = join2(cacheRoot(), "objects");
128+
let sweep = sweeps.get(objects);
129+
if (sweep === void 0) {
130+
sweep = removeAbandoned();
131+
sweeps.set(objects, sweep);
132+
}
133+
return sweep;
134+
async function removeAbandoned() {
135+
const names = await readdir(objects).catch(() => []);
136+
await Promise.all(
137+
names.filter((name) => TEMP_DIR.test(name)).map(async (name) => {
138+
const dir = join2(objects, name);
139+
const age = await ageMs(dir);
140+
if (age !== void 0 && age > ABANDONED_TEMP_DIR_MS)
141+
await rm2(dir, { recursive: true, force: true }).catch(() => {
142+
});
143+
})
144+
);
145+
}
146+
}
147+
async function ageMs(path) {
148+
return stat(path).then(
149+
(s) => Date.now() - s.ctimeMs,
150+
() => void 0
151+
);
152+
}
153+
154+
// src/cli.ts
155+
var USAGE = `usage: coana-cache.mjs lookup <version> <component>
156+
coana-cache.mjs install <version> <component> <sha256> <extracted-dir>`;
157+
var NOT_CACHED = 4;
158+
async function main(args) {
159+
const [command, ...rest] = args;
160+
if (command === "lookup" && rest.length === 2) {
161+
const [version, component] = rest;
162+
const dir = await lookup(version, component);
163+
if (dir === void 0) return NOT_CACHED;
164+
process.stdout.write(`${dir}
165+
`);
166+
return 0;
167+
}
168+
if (command === "install" && rest.length === 4) {
169+
const [version, component, sha256, extracted] = rest;
170+
const dir = await install(version, component, sha256, (tmp) => moveInto(extracted, tmp));
171+
await rm3(extracted, { recursive: true, force: true }).catch(() => {
172+
});
173+
process.stdout.write(`${dir}
174+
`);
175+
return 0;
176+
}
177+
process.stderr.write(`${USAGE}
178+
`);
179+
return 2;
180+
async function moveInto(extractedDir, dir) {
181+
for (const name of await readdir2(extractedDir)) {
182+
const from = join3(extractedDir, name);
183+
const to = join3(dir, name);
184+
try {
185+
await rename2(from, to);
186+
} catch (e) {
187+
if (e.code !== "EXDEV") throw e;
188+
await cp(from, to, { recursive: true, verbatimSymlinks: true });
189+
}
190+
}
191+
}
192+
}
193+
process.exitCode = await main(process.argv.slice(2)).catch((e) => {
194+
process.stderr.write(`${e instanceof Error ? e.message : String(e)}
195+
`);
196+
if (e instanceof InvalidCacheKey) return 2;
197+
return e instanceof CacheNotWritable ? 3 : 1;
198+
});

0 commit comments

Comments
 (0)