Compare commits

...
Author SHA1 Message Date
l0ng-ai faae2b36ac fix(run): align test runner status icons and filters 2026-11-10 14:09:09 +08:00
Vadim Lazukandl0ng-ai 565ada6b86 feat(dap): debug C/C++ with GDB's native DAP interpreter (#2412)
* feat(dap): debug C/C++ with GDB's native DAP interpreter

Fixes #2338

Native debugging was LLDB-only (lldb-dap, CodeLLDB), so toolchains and
embedded setups that ship GDB (pretty printers, .gdbinit, gdbserver
targets) had no debugger in the IDE. Adds a built-in "gdb" adapter:
`gdb -i dap` over stdio, with an install hint. GDB's DAP interpreter
shipped in GDB 14, so a launch with an older GDB is refused with the
found and the required version in the message (prepare_gdb, a probe
like debugpy's). C/C++ run configurations pick the debugger in the
Debug adapter control - GDB is an explicit choice there, the default
stays lldb-dap. The c-app fixture (C and C++ entry points) is debugged
with GDB in the language smoke; real_adapters gains a gdb case that
skips itself without a GDB 14+ on PATH.

* fix(dap): start GDB 15 sessions with the launch after the configuration

GDB 15 starts the program inside its launch request, so the launch-first
order every other adapter uses races the breakpoint upload: the run can
be past the breakpoints before setBreakpoints lands, and configurationDone
is refused with notStopped while the program is off. GDB 14 runs the
program at configurationDone and 16+ defers the whole launch there, so
both are fine with launch-first; 15.x is the one version that needs the
launch to go out after configurationDone. The order now follows a
gdb --version probe (only for the gdb adapter); a fake adapter speaking
GDB 15's wire behavior pins the ordering and the notStopped refusal.

* fix(dap): harden GDB startup and expression evaluation

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-10 13:49:14 +08:00
l0ng-ai 876faa7ba2 fix(ci): make main's format check and platform-specific tests pass (#2477)
README.md had trailing blank lines oxfmt rejects. On macOS a plugin workbench test compared the pinned program's canonical /private/var path with the raw /var temp path. On Windows the IntelliJ settings test set a modification time through a read-only handle, and the JAR import test expected forward slashes where $PROJECT_DIR$ in program parameters expands to the native root. A remote plugin run test also read its output file before the run had written it; both waits there now wait for the complete content.
2026-11-10 13:40:26 +08:00
Vadim LazukandInf1mum 11594437b8 feat(plugins): ask at first connect to a host the manifest does not list (net:ask) (#2417)
Plugins that declare net:ask can reach an HTTPS or loopback host their manifest does not list after the user allows it: Allow once, Always allow (remembered in the grant ledger, revocable in Settings › Plugins) or Deny. Unattended calls never prompt and are denied until the host is allowed. In remote development the dialog offers Allow for this session instead; the approval stays in memory on the server and ends with the connection, so no grant exists that the desktop cannot list or revoke.

Closes #2165

Co-authored-by: Inf1mum <[email protected]>
2026-11-10 12:31:45 +08:00
l0ng-ai bfe99c06e2 ci: build nightly versions twice a day (#2136)
Adds a nightly channel. Twice a day nightly.yml builds the newest main commit that passed CI and publishes it as a prerelease (OpenIDEA Nightly, ai.l0ng.openidea.nightly, own data and update manifest), or stops when nothing is newer than the last nightly. Platform builds move from release.yml into the reusable release-build.yml so both channels build the same way.

Versions are one patch above main with a timestamp suffix; each build gets its own v…-nightly.… tag (remote development downloads the server by version) and the last 14 are kept; a rolling nightly release holds latest.json. No MSI or RPM for nightlies. Nightly builds skip the CNB mirror manifest, and their automatic update prompts (startup and periodic) appear at most once a day; a manual check always answers.

Workflow permissions default to contents: read with write granted per job, Apple signing secrets reach only macOS builds, stable release.yml rejects nightly tags, tag pruning only matches -nightly. tags, and nightly runs share one concurrency group.
2026-11-10 12:30:17 +08:00
lleviathe f50d3099ce test(smoke): smoke-test PHP on a php-app fixture (#2189)
PHP had no regression coverage. Adds a php-app fixture (Composer + Pest) with language, run and debug smoke cases, and a php entry in the Language smoke workflow: PHP 8.5 with Xdebug via setup-php, Intelephense pinned like the other servers. Other languages' selectors are unchanged.

Co-authored-by: lleviathe <[email protected]>
2026-11-10 12:10:51 +08:00
lleviathe 00573f448f fix(dev): keep the dev identity of a moved checkout (#2078)
build.rs read CARGO_MANIFEST_DIR through env!(), which is fixed when the build script is compiled. Cargo reruns that binary after a checkout moves without recompiling it, so the script panicked when the old path was gone, or kept the old path's dev identity when something still existed there. The script now reads CARGO_MANIFEST_DIR at run time; the Windows manifest path for the linker follows the move too.

Co-authored-by: lleviathe <[email protected]>
2026-11-10 12:02:01 +08:00
l0ng-ai 573bac6b7a fix(plugins): confine resolved write destinations (#2436) 2026-11-10 11:55:52 +08:00
Shohruh Ismoilovandl0ng-ai 5e7304a750 fix(go): resolve Delve targets with the selected compiler (#1997)
* fix(go): resolve Delve targets with the selected compiler

* fix(go): honor Windows compiler environment casing

* fix(deps): bundle patched SQLite for RUSTSEC-2026-0459

---------

Co-authored-by: l0ng-ai <[email protected]>
2026-11-10 10:47:40 +08:00
arookieofc 5d86688130 feat(agents): show running/done status on session and terminal tabs (#2233) (#2274)
An agent task's editor tab now shows a spinner while the task is busy and a
status dot once it finishes, failing or limited shown in danger. Terminal
tabs show the same: a spinner while a command runs (or a one-shot program
session is still starting) and an exited / failed dot afterwards.

The editor kind contribution gains an optional trailing(input) callback
rendered in the tab's trailing slot, next to the pin icon; the agents
feature uses it for a TaskStatusMark reading the task status out of the
agents store. The store bumps the tab-title revision on status changes
too, not only on renames, so the mark follows live.

Closes #2233
2026-11-10 10:45:26 +08:00
111 changed files with 3675 additions and 441 deletions

No files matched your search

+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
if: runner.os == 'Linux'
run: git diff --exit-code src/bindings.ts
# The remote development server ships as a static Linux binary (release.yml):
# The remote development server ships as a static Linux binary (release-build.yml):
# keep that build working.
server:
name: Remote server (linux musl)
+17 -2
View File
@@ -47,6 +47,7 @@ env:
JDTLS: 1.63.0-202610191322
KOTLIN_LSP: 263.7084.0
GRADLE: 8.14.3
INTELEPHENSE: 1.18.5
jobs:
smoke:
@@ -54,8 +55,9 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [typescript, python, rust, go, java, kotlin]
runs-on: ubuntu-22.04
language: [typescript, python, rust, go, java, kotlin, php, c, cpp]
# C/C++ debug with GDB, whose DAP interpreter needs 14+: 22.04 ships 12.1, 24.04 ships 15.
runs-on: ${{ (matrix.language == 'c' || matrix.language == 'cpp') && 'ubuntu-24.04' || 'ubuntu-22.04' }}
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
@@ -74,6 +76,12 @@ jobs:
- if: matrix.language == 'typescript'
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
# The smoke builds the c-app fixture and debugs it with GDB's own DAP interpreter.
- if: matrix.language == 'c' || matrix.language == 'cpp'
run: |
sudo apt-get update
sudo apt-get install -y gdb
- if: matrix.language == 'python'
uses: actions/setup-python@v5
with: { python-version: "3.12" }
@@ -108,6 +116,13 @@ jobs:
ln -s "$RUNNER_TEMP/kotlin-lsp/kotlin-lsp.sh" "$RUNNER_TEMP/bin/kotlin-lsp"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
# Xdebug for the debug smoke; Composer installs the fixture's Pest for the run smoke.
- if: matrix.language == 'php'
uses: shivammathur/setup-php@v2
with: { php-version: "8.5", coverage: xdebug, tools: composer }
- if: matrix.language == 'php'
run: npm install --global "intelephense@$INTELEPHENSE"
- run: cargo test --locked -p ide-host --test language_smoke --no-run
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
- if: matrix.language == 'java'
+119
View File
@@ -0,0 +1,119 @@
name: Nightly
# Twice a day, builds the newest main commit that passed CI as a prerelease tagged with its own
# version, so remote development finds the server of that version. The `nightly` release holds
# the manifest the nightly app updates from; its tag marks the commit last built, and a run with
# nothing newer stops there. Only the latest builds are kept.
on:
schedule:
- cron: "17 2,14 * * *"
workflow_dispatch:
inputs:
force:
description: "Build even if nothing is newer than the last nightly"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: nightly
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
jobs:
prepare:
permissions:
actions: read
contents: write
if: github.repository == 'l0ng-ai/openidea'
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.commit.outputs.sha }}
tag: ${{ steps.release.outputs.tag }}
release_id: ${{ steps.release.outputs.id }}
steps:
- id: commit
env:
FORCE: ${{ inputs.force }}
run: |
sha="$(gh run list --workflow ci.yml --branch main --event push --status success --limit 1 \
--json headSha --jq '.[0].headSha // ""')"
if [ -z "$sha" ]; then
echo "::notice::no main commit has passed CI"
exit 0
fi
# No `nightly` tag yet (the first run): compare fails and the build goes on.
status="$(gh api "repos/$GH_REPO/compare/nightly...$sha" --jq .status 2>/dev/null || echo ahead)"
if [ "$status" != ahead ] && [ "$FORCE" != true ]; then
echo "::notice::$sha is not newer than the last nightly"
exit 0
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v5
if: steps.commit.outputs.sha != ''
with: { ref: "${{ steps.commit.outputs.sha }}" }
- uses: actions/setup-node@v5
if: steps.commit.outputs.sha != ''
with: { node-version: 22 }
- uses: dtolnay/rust-toolchain@stable
if: steps.commit.outputs.sha != ''
- id: release
if: steps.commit.outputs.sha != ''
env:
SHA: ${{ steps.commit.outputs.sha }}
run: |
version="$(node scripts/nightly-prepare.ts "$(date -u +%Y%m%d%H%M)")"
tag="v$version"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -am "chore: $tag"
git tag "$tag"
git push origin "$tag"
id="$(gh api -X POST "repos/$GH_REPO/releases" -f tag_name="$tag" -f name="OpenIDEA Nightly $version" \
-F draft=true -F prerelease=true -f body="Built from $SHA." --jq .id)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
echo "id=$id" >> "$GITHUB_OUTPUT"
binaries:
permissions:
contents: write
needs: prepare
if: needs.prepare.outputs.tag != ''
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ needs.prepare.outputs.tag }}
release_id: ${{ needs.prepare.outputs.release_id }}
secrets: inherit
publish:
permissions:
contents: write
needs: [prepare, binaries]
runs-on: ubuntu-latest
env:
TAG: ${{ needs.prepare.outputs.tag }}
SHA: ${{ needs.prepare.outputs.sha }}
KEEP: 14
steps:
- name: Publish the build
run: gh release edit "$TAG" --tag "$TAG" --draft=false
- name: Point the updater at it
run: |
gh release download "$TAG" -p latest.json -D "$RUNNER_TEMP"
gh release view nightly >/dev/null 2>&1 ||
gh release create nightly --prerelease --latest=false --target "$SHA" --title "OpenIDEA Nightly" \
--notes "The update manifest of the nightly builds, and the commit last built."
gh release upload nightly "$RUNNER_TEMP/latest.json" --clobber
gh api -X PATCH "repos/$GH_REPO/git/refs/tags/nightly" -f sha="$SHA" -F force=true --silent
# Failed builds' drafts go too, once they are this old.
- name: Delete old builds
run: |
gh release list --limit 200 --json tagName,createdAt --jq \
'[.[] | select(.tagName | contains("-nightly."))] | sort_by(.createdAt) | reverse | .[env.KEEP | tonumber:][].tagName' |
while read -r tag; do
gh release delete "$tag" --cleanup-tag --yes
done
+262
View File
@@ -0,0 +1,262 @@
name: Release build
# Builds a tag's desktop apps, remote servers and updater manifest into its draft release.
# Stable releases (release.yml) and nightly builds (nightly.yml) both run it.
on:
workflow_call:
inputs:
tag: { type: string, required: true }
release_id: { type: string, required: true }
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
TAG: ${{ inputs.tag }}
jobs:
plugin-keys:
permissions: {}
runs-on: ubuntu-latest
outputs:
keys: ${{ steps.keys.outputs.keys }}
steps:
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
build:
permissions:
contents: write
needs: plugin-keys
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.plugin-keys.outputs.keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE || '' }}
APPLE_CERTIFICATE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}
APPLE_SIGNING_IDENTITY: ${{ runner.os == 'macOS' && secrets.APPLE_SIGNING_IDENTITY || '' }}
APPLE_ID: ${{ runner.os == 'macOS' && secrets.APPLE_ID || '' }}
APPLE_PASSWORD: ${{ runner.os == 'macOS' && secrets.APPLE_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ runner.os == 'macOS' && secrets.APPLE_TEAM_ID || '' }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ inputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
# Release assets have no spaces: GitHub stored the first upload with dots.
asset="$RUNNER_TEMP/$(basename "$dmg" | tr ' ' .)"
cp "$dmg" "$asset"
gh release upload "$TAG" "$asset" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
permissions:
contents: write
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
permissions:
contents: write
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ inputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
// Nightly versions don't fit MSI or RPM, so those builds offer NSIS on Windows.
const nightly = tag.includes("-nightly.");
const windows = (arch, msi, nsis) => nightly
? [[nsis, [`windows-${arch}`, `windows-${arch}-nsis`]]]
: [[msi, [`windows-${arch}`, `windows-${arch}-msi`]], [nsis, [`windows-${arch}-nsis`]]];
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
...(nightly ? [] : [[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]]]),
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
...(nightly ? [] : [[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]]]),
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
...windows("aarch64", /_arm64_en-US\.msi$/, /_arm64-setup\.exe$/),
...windows("x86_64", /_x64_en-US\.msi$/, /_x64-setup\.exe$/),
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
+24 -229
View File
@@ -1,7 +1,7 @@
name: Release
on:
push:
tags: ["v*"]
tags: ["v*", "!v*-nightly.*"]
workflow_dispatch:
inputs:
tag:
@@ -9,7 +9,7 @@ on:
required: true
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.event.inputs.tag || github.ref_name }}
@@ -23,10 +23,12 @@ env:
jobs:
# Creates (or reuses) one draft release that every platform build uploads into.
create-release:
if: ${{ !contains(github.event.inputs.tag || github.ref_name, '-nightly.') }}
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.release.outputs.id }}
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
@@ -47,20 +49,6 @@ jobs:
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
# the secret the app takes the index's word, as development builds do.
- id: plugin-keys
env:
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
run: |
if [ -n "$SIGNING_KEY" ]; then
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
echo "keys=$key" >> "$GITHUB_OUTPUT"
fi
- id: release
uses: actions/github-script@v8
with:
@@ -74,161 +62,26 @@ jobs:
core.setOutput("id", existing.id);
return;
}
// Nightly builds are prereleases: the notes span stable release to stable release.
const previous = releases.find((r) => !r.draft && !r.prerelease);
const { data: notes } = await github.rest.repos.generateReleaseNotes({
owner, repo, tag_name: tag, previous_tag_name: previous?.tag_name,
});
const { data } = await github.rest.repos.createRelease({
owner, repo, tag_name: tag, name: `OpenIDEA ${tag}`,
draft: true, prerelease: tag.includes("-"), generate_release_notes: true,
draft: true, prerelease: tag.includes("-"), body: notes.body,
});
core.setOutput("id", data.id);
build:
binaries:
permissions:
contents: write
needs: create-release
name: Build (${{ matrix.name }})
env:
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
strategy:
fail-fast: false
matrix:
include:
- name: macOS arm64
os: macos-latest
target: aarch64-apple-darwin
- name: macOS x64
os: macos-latest
target: x86_64-apple-darwin
- name: Linux x64
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
- name: Linux arm64
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
- name: Windows arm64
os: windows-11-arm
target: aarch64-pc-windows-msvc
- name: Windows x64
os: windows-latest
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: pnpm/action-setup@v5
with: { version: 11 }
- uses: actions/setup-node@v5
with: { node-version: 22, cache: pnpm }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "${{ matrix.target }}" }
- name: Linux deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev libssl-dev patchelf
- run: pnpm install --frozen-lockfile
# Signing is optional: only secrets that are set get exported, so forks and
# unconfigured repos produce unsigned builds instead of failing.
- name: Signing env
shell: bash
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
for v in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID \
APPLE_PASSWORD APPLE_TEAM_ID TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do
[ -n "${!v}" ] && { echo "$v<<__EOF__"; echo "${!v}"; echo "__EOF__"; } >> "$GITHUB_ENV"
done
true
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ env.TAG }}
args: --target ${{ matrix.target }}
# The parallel builds would race on one latest.json; `updater-manifest` writes it once.
uploadUpdaterJson: false
# tauri-action notarizes the .app but not the DMG around it; notarize and
# staple the DMG too, then replace the uploaded asset.
- name: Notarize DMG
if: runner.os == 'macOS' && env.APPLE_ID != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
for dmg in target/${{ matrix.target }}/release/bundle/dmg/*.dmg; do
xcrun notarytool submit "$dmg" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" \
--team-id "$APPLE_TEAM_ID" --wait
xcrun stapler staple "$dmg"
gh release upload "$TAG" "$dmg" --clobber
done
# Portable Windows builds (#1857): on Windows Tauri embeds the
# frontend in the exe and WebView2 is a system component, so zipping
# the raw exe gives an unpack-and-run alternative to the installers.
- name: Portable zip
if: runner.os == 'Windows'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
$target = "${{ matrix.target }}"
$arch = if ($target -like "x86_64*") { "x64" } else { "arm64" }
$version = $env:TAG -replace '^v', ''
$asset = "OpenIDEA_${version}_windows_${arch}_portable.zip"
$dir = "target/$target/release"
$files = @("$dir/openidea.exe")
if (Test-Path "$dir/WebView2Loader.dll") {
$files += "$dir/WebView2Loader.dll"
}
Compress-Archive -Path $files -DestinationPath $asset -Force
$hash = (Get-FileHash $asset -Algorithm SHA256).Hash.ToLower()
"$hash $asset" | Out-File -Encoding ascii "$asset.sha256"
gh release upload $env:TAG $asset "$asset.sha256" --clobber
# The remote development server, per host platform it can run on. The desktop
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
# Linux builds are static (musl), so they run whatever the host's glibc.
server:
needs: create-release
name: Server (${{ matrix.os }}-${{ matrix.arch }})
strategy:
fail-fast: false
matrix:
include:
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with: { ref: "${{ env.TAG }}" }
- uses: dtolnay/rust-toolchain@stable
with: { targets: "${{ matrix.target }}" }
- uses: Swatinem/rust-cache@v2
with: { key: "server-${{ matrix.target }}" }
- name: musl toolchain
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- name: Build
run: |
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
env:
TARGET: ${{ matrix.target }}
- name: Upload
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
run: |
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
uses: ./.github/workflows/release-build.yml
with:
tag: ${{ github.event.inputs.tag || github.ref_name }}
release_id: ${{ needs.create-release.outputs.release_id }}
secrets: inherit
# The official plugins, signed, and the marketplace index released apps read
# (releases/latest/download/plugins-index.json). They are not in the
@@ -240,6 +93,8 @@ jobs:
# openidea-plugin tool for Linux x64, which that workflow and the registry's
# checks run.
plugins:
permissions:
contents: write
needs: create-release
name: Official plugins
runs-on: ubuntu-22.04
@@ -303,70 +158,10 @@ jobs:
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
"$out/openidea-plugin-linux-x86_64.gz" --clobber
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
# Fails when a platform is missing, so a release never ships a manifest without it.
updater-manifest:
needs: [create-release, build]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.create-release.outputs.release_id }}
with:
script: |
const { owner, repo } = context.repo;
const release_id = Number(process.env.RELEASE_ID);
const tag = process.env.TAG;
const assets = await github.paginate(github.rest.repos.listReleaseAssets, { owner, repo, release_id });
const sigs = assets.filter((a) => a.name.endsWith(".sig"));
if (sigs.length === 0) {
core.notice("no signed bundles (TAURI_SIGNING_PRIVATE_KEY unset): skipping latest.json");
return;
}
// Bundle suffix → updater platform keys, as tauri-action names them.
const kinds = [
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
];
const platforms = {};
for (const sig of sigs) {
const bundle = sig.name.slice(0, -".sig".length);
const kind = kinds.find(([re]) => re.test(bundle));
if (!kind) continue;
const { data } = await github.rest.repos.getReleaseAsset({
owner, repo, asset_id: sig.id, headers: { accept: "application/octet-stream" },
});
const signature = Buffer.from(data).toString("utf8").trim();
const url = `https://github.com/${owner}/${repo}/releases/download/${tag}/${encodeURIComponent(bundle)}`;
for (const key of kind[1]) platforms[key] = { signature, url };
}
const missing = kinds.flatMap(([, keys]) => keys).filter((k) => !platforms[k]);
if (missing.length) {
core.setFailed(`latest.json would miss ${missing.join(", ")}`);
return;
}
const manifest = { version: tag.replace(/^v/, ""), notes: "", pub_date: new Date().toISOString(), platforms };
const old = assets.find((a) => a.name === "latest.json");
if (old) await github.rest.repos.deleteReleaseAsset({ owner, repo, asset_id: old.id });
await github.rest.repos.uploadReleaseAsset({
owner, repo, release_id, name: "latest.json",
data: JSON.stringify(manifest, null, 2),
headers: { "content-type": "application/json" },
});
core.info(`latest.json: ${Object.keys(platforms).sort().join(", ")}`);
publish:
needs: [create-release, build, server, plugins, updater-manifest]
permissions:
contents: write
needs: [create-release, binaries, plugins]
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v8
-2
View File
@@ -76,6 +76,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+197 -3
View File
@@ -103,6 +103,14 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
}
// GDB's own DAP interpreter, stdio transport. The DAP support it needs shipped in GDB 14
// (`prepare_gdb` refuses older ones with the required version in the message).
"gdb" => cfg(
"gdb",
&["-i", "dap"],
"stdio",
"Install GDB 14 or newer (apt install gdb, dnf install gdb, brew install gdb)",
),
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
"java-debug" => cfg(
@@ -129,6 +137,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
"delve" => "go",
"netcoredbg" => "coreclr",
"lldb-dap" => "lldb-dap",
"gdb" => "gdb",
"java-debug" => "java",
_ => return None,
})
@@ -335,6 +344,89 @@ pub async fn prepare_debugpy(spec: &mut AdapterSpec) -> IdeResult<()> {
)))
}
/// GDB's DAP interpreter (`-i dap`) shipped in GDB 14; older ones exit with
/// "Interpreter `dap' unrecognized".
const GDB_MIN_MAJOR: u32 = 14;
/// `(major, minor)` of the first version number in the output of `gdb --version`
/// (`GNU gdb (GDB) 14.2`, `GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1`, `GNU gdb
/// (GDB) 15.0.50.20240403-0ubuntu1`). `None` when no standalone version number is there.
pub fn parse_gdb_version(output: &str) -> Option<(u32, u32)> {
let line = output.lines().next()?;
// Distribution names can contain versions, e.g. `(GDB; SUSE Linux Enterprise 15) 14.2`.
let line = line.rsplit_once(')').map_or(line, |(_, version)| version);
let b = line.as_bytes();
let mut i = 0;
while i < b.len() {
if !b[i].is_ascii_digit() {
i += 1;
continue;
}
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
let major: u32 = line[start..i].parse().ok()?;
let mut minor = 0;
if i + 1 < b.len() && b[i] == b'.' && b[i + 1].is_ascii_digit() {
i += 1;
let ms = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
minor = line[ms..i].parse().unwrap_or(0);
}
// A version stands alone: `mingw32-gdb13.exe`'s embedded digits are part of a word.
if start > 0 && (b[start - 1].is_ascii_alphabetic() || b[start - 1] == b'_') {
continue;
}
return Some((major, minor));
}
None
}
/// Probe the already resolved and verified executable with its launch environment.
async fn gdb_version(binary: &Path, spec: &AdapterSpec) -> IdeResult<Option<(u32, u32)>> {
let mut probe = Command::new(binary);
if let Some(path) = adapter_path(&spec.env) {
probe.env("PATH", path);
}
ide_core::process::prepare(probe.as_std_mut());
let probe = probe
.arg("--version")
.current_dir(&spec.cwd)
.envs(spec.env.iter().cloned())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output();
let Ok(Ok(out)) = tokio::time::timeout(Duration::from_secs(15), probe).await else {
return Ok(None);
};
Ok(parse_gdb_version(&String::from_utf8_lossy(&out.stdout)))
}
/// Refuse old GDB versions and report whether launch must follow configurationDone.
/// GDB 15 starts the inferior in launch; 14 and 16+ start it at configurationDone.
async fn prepare_gdb(spec: &AdapterSpec, binary: &Path) -> IdeResult<bool> {
let Some((major, minor)) = gdb_version(binary, spec).await? else {
return Ok(false);
};
if major >= GDB_MIN_MAJOR {
return Ok(major == 15);
}
let mut msg = format!(
"debugging with GDB needs GDB {GDB_MIN_MAJOR} or newer (found {major}.{minor}): \
this one has no DAP interpreter (`gdb -i dap`)"
);
if let Some(hint) = &spec.install_hint {
msg.push_str(". ");
msg.push_str(hint);
}
Err(IdeError::unavailable(msg))
}
/// An adapter that is a plugin of another program (java-debug, a jdtls bundle) rather
/// than a program: it has no process of its own and is reached through `debugServer`.
pub fn is_plugin_bundle(command: &str) -> bool {
@@ -368,6 +460,8 @@ pub type BoxWriter = Box<dyn AsyncWrite + Unpin + Send>;
/// A connected adapter: byte streams plus the process (if we spawned one).
pub struct AdapterIo {
/// GDB 15 starts running inside `launch`; configure breakpoints before sending it.
pub launch_after_configuration: bool,
pub reader: BoxReader,
pub writer: BoxWriter,
pub child: Option<Child>,
@@ -419,6 +513,7 @@ impl AdapterLauncher for ProcessLauncher {
let stream = connect_with_retries(host, port, None, timeout).await?;
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -442,6 +537,7 @@ impl AdapterLauncher for ProcessLauncher {
let _ = stream.set_nodelay(true);
let (r, w) = stream.into_split();
return Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -470,9 +566,15 @@ impl AdapterLauncher for ProcessLauncher {
.ok_or_else(|| missing(&spec))?
}
};
let launch_after_configuration =
if spec.adapter == "gdb" && spec.target.is_none() { prepare_gdb(&spec, &binary).await? } else { false };
let base_args = substitute_root(&spec.args, &spec.cwd);
match spec.transport {
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
Transport::Stdio => {
let mut io = spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await?;
io.launch_after_configuration = launch_after_configuration;
Ok(io)
}
Transport::Tcp => {
// An embedded adapter started without `${port}` listens on its usual
// address; every other TCP adapter gets a port chosen here so it can be
@@ -489,7 +591,10 @@ impl AdapterLauncher for ProcessLauncher {
};
let args = substitute_port(&base_args, port);
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
Ok(io) => return Ok(io),
Ok(mut io) => {
io.launch_after_configuration = launch_after_configuration;
return Ok(io);
}
Err(e) => {
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
tracing::warn!(
@@ -552,6 +657,7 @@ async fn spawn_adapter(
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(stdout),
writer: Box::new(stdin),
child: Some(child),
@@ -583,6 +689,7 @@ async fn spawn_adapter(
};
let (r, w) = stream.into_split();
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(r),
writer: Box::new(w),
child: Some(child),
@@ -740,15 +847,102 @@ mod tests {
#[test]
fn builtins_cover_common_adapters() {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug"] {
for name in ["codelldb", "debugpy", "js-debug", "delve", "netcoredbg", "java-debug", "gdb"] {
let cfg = builtin(name).unwrap();
assert!(cfg.install_hint.is_some());
}
let delve = resolve(&builtin("delve").unwrap(), PathBuf::from("/"));
assert_eq!(delve.transport, Transport::Tcp);
// GDB's own DAP interpreter over stdio, with the required version in its hint.
let gdb = builtin("gdb").unwrap();
assert_eq!(gdb.command, "gdb");
assert_eq!(gdb.args, ["-i", "dap"]);
assert_eq!(gdb.transport, "stdio");
assert!(gdb.install_hint.as_deref().is_some_and(|h| h.contains("14")), "{gdb:?}");
assert_eq!(default_type("gdb"), Some("gdb"));
assert!(builtin("nope").is_none());
}
/// The first version number of `gdb --version`'s first line, however the distro decorates it.
#[test]
fn parses_gdb_versions() {
for (out, want) in [
("GNU gdb (GDB) 14.2\nCopyright (C) 2023 Free Software Foundation", Some((14, 2))),
("GNU gdb (GDB) 15.0.50.20240403-0ubuntu1\n", Some((15, 0))),
("GNU gdb (Ubuntu 12.1-0ubuntu1~22.04.2) 12.1", Some((12, 1))),
("GNU gdb (Debian 13.1-3) 13.1", Some((13, 1))),
("GNU gdb (GDB) Fedora Linux 16.3-1.fc41", Some((16, 3))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 14.2", Some((14, 2))),
("GNU gdb (GDB; SUSE Linux Enterprise 15) 11.1", Some((11, 1))),
("mingw32-gdb13.exe (GDB) 13.2", Some((13, 2))),
("GNU gdb 14", Some((14, 0))),
("no version here", None),
("", None),
] {
assert_eq!(parse_gdb_version(out), want, "{out}");
}
}
#[tokio::test]
async fn gdb_probe_never_executes_a_changed_pinned_program() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
std::fs::write(&fake, "approved").unwrap();
let pin = ProgramPin::approve(&fake).unwrap();
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\necho ran>probe-ran\r\necho GNU gdb (GDB) 15.1\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\ntouch probe-ran\necho 'GNU gdb (GDB) 15.1'\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
spec.command = fake.to_string_lossy().into_owned();
spec.pinned = Some(pin);
let error = ProcessLauncher.launch(spec, None).await.err().expect("changed program is refused");
assert_eq!(error.kind, ide_core::error::ErrorKind::PermissionDenied);
assert!(!dir.path().join("probe-ran").exists(), "the version probe executed an unapproved program");
}
#[tokio::test]
async fn gdb_probe_uses_the_verified_program_and_launch_environment_once() {
use tokio::io::AsyncReadExt;
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join(if cfg!(windows) { "gdb.cmd" } else { "gdb" });
#[cfg(windows)]
std::fs::write(&fake, "@echo off\r\nif \"%~1\"==\"--version\" (\r\n echo probe>>probes\r\n echo GNU gdb ^(GDB^) %GDB_TEST_VERSION%\r\n) else (\r\n echo adapter\r\n)\r\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::write(&fake, "#!/bin/sh\nif [ \"$1\" = --version ]; then\n echo probe >> probes\n echo \"GNU gdb (GDB) $GDB_TEST_VERSION\"\nelse\n echo adapter\nfi\n").unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let pin = ProgramPin::approve(&fake).unwrap();
for (version, deferred) in [("12.1", false), ("14.2", false), ("15.1", true), ("16.3", false)] {
let mut spec = resolve(&builtin("gdb").unwrap(), dir.path().to_owned());
// Only the approved path is executable; the symbolic command must never be probed.
spec.command = "definitely-not-a-real-binary-openidea".into();
spec.pinned = Some(pin.clone());
spec.env.push(("GDB_TEST_VERSION".into(), version.into()));
let result = ProcessLauncher.launch(spec, None).await;
if version == "12.1" {
let error = result.err().expect("GDB 12 has no DAP interpreter");
assert_eq!(error.kind, ide_core::error::ErrorKind::Unavailable);
assert!(error.message.contains("GDB 14 or newer"), "{}", error.message);
assert!(error.message.contains("12.1"), "{}", error.message);
} else {
let mut io = result.unwrap();
assert_eq!(io.launch_after_configuration, deferred, "{version}");
let mut output = String::new();
io.reader.read_to_string(&mut output).await.unwrap();
assert_eq!(output.trim(), "adapter");
io.child.as_mut().unwrap().wait().await.unwrap();
}
}
assert_eq!(std::fs::read_to_string(dir.path().join("probes")).unwrap().lines().count(), 4);
}
#[test]
fn free_port_is_nonzero() {
assert_ne!(free_port().unwrap(), 0);
+50 -12
View File
@@ -746,6 +746,10 @@ impl DebugManager {
{
spec.env.push(("JAVA_HOME".into(), home));
}
// Delve looks up Go before applying the debuggee's launch environment.
if config.adapter == "delve" {
spec.env.extend(config.env.iter().map(|(key, value)| (key.clone(), value.clone())));
}
if config.adapter == "rdbg" {
rdbg_command_line(&mut spec, &config, &cwd)?;
}
@@ -778,6 +782,18 @@ impl DebugManager {
if let Some(server) = &debug_server {
spec.server_host = server.host.clone();
}
if config.adapter == "gdb" && config.request == "launch" && debug_server.is_none() {
// GDB's DAP env replaces the entire inferior environment. Ordinary run overrides
// instead belong on the adapter process, which the inferior inherits. Explicit
// launch JSON keeps GDB's replacement semantics.
let explicit_env = config.extra_json.as_deref().is_some_and(|extra| {
serde_json::from_str::<Value>(extra).is_ok_and(|extra| extra.get("env").is_some())
});
if !explicit_env {
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
arguments.as_object_mut().unwrap().remove("env");
}
}
if let Some(cargo) = arguments.as_object_mut().and_then(|o| o.remove("cargo")) {
let cargo_args: Vec<String> = cargo
.get("args")
@@ -893,6 +909,7 @@ impl DebugManager {
e
}
})?;
let launch_after_configuration = io.launch_after_configuration && request == "launch";
let stderr = io.stderr.clone();
let (conn, incoming) = {
let stderr = stderr.clone();
@@ -935,7 +952,7 @@ impl DebugManager {
let configured = tokio::select! {
biased;
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
result = self.configure(&session, arguments) => result,
result = self.configure(&session, arguments, launch_after_configuration) => result,
};
match configured {
Ok(()) => Ok(id),
@@ -976,7 +993,7 @@ impl DebugManager {
}
}
async fn configure(&self, s: &Arc<Session>, mut arguments: Value) -> IdeResult<()> {
async fn configure(&self, s: &Arc<Session>, mut arguments: Value, launch_in_config: bool) -> IdeResult<()> {
if s.adapter == kotlin::ADAPTER {
let project = arguments
.get("projectRoot")
@@ -1042,16 +1059,28 @@ impl DebugManager {
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
// failure; Stop cancels configuration and a closed connection fails the pending reply.
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
let launch = s.conn.send_request(request, arguments)?;
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
// GDB 15 is the exception: its `launch` starts the program itself (14 runs at
// `configurationDone`, 16+ defers the launch there), so its launch waits below.
let mut launch = if launch_in_config {
None
} else {
let reply = s.conn.send_request(request, arguments.clone())?;
Some(Box::pin(async move { reply.await.unwrap_or(Err(DapError::Closed)) }))
};
let mut initialized = s.initialized.subscribe();
let mut launch_result = None;
let got_initialized = tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = &mut launch => {
r?;
launch_result = Some(());
false
let got_initialized = match launch.as_mut() {
Some(launch) => tokio::select! {
r = initialized.wait_for(|v| *v) => { r.map_err(|_| DapError::Closed)?; true }
r = launch => {
r?;
launch_result = Some(());
false
}
},
None => {
initialized.wait_for(|v| *v).await.map_err(|_| DapError::Closed)?;
true
}
};
if !got_initialized {
@@ -1109,8 +1138,17 @@ impl DebugManager {
}
}
s.configured.store(true, Ordering::Release);
if launch_result.is_none() {
launch.await?;
match launch.as_mut() {
Some(launch) => {
if launch_result.is_none() {
launch.await?;
}
}
None => {
// GDB 15: the breakpoints are in, so its `run` cannot miss them.
let reply = s.conn.send_request(request, arguments)?;
reply.await.unwrap_or(Err(DapError::Closed))?;
}
}
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
relay.release();
+135 -18
View File
@@ -126,6 +126,10 @@ struct Script {
pending_startup: Option<bool>,
startup_error: Option<&'static str>,
close_during_startup: bool,
/// GDB 15 semantics: `initialized` right after `initialize`, the program starts at
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
gdb15_starts_at_launch: bool,
launch_after_configuration: bool,
}
impl Default for Script {
@@ -145,6 +149,8 @@ impl Default for Script {
pending_startup: None,
startup_error: None,
close_during_startup: false,
gdb15_starts_at_launch: false,
launch_after_configuration: false,
}
}
}
@@ -159,10 +165,12 @@ impl AdapterLauncher for FakeLauncher {
let (client, server) = tokio::io::duplex(1 << 16);
let log = self.log.clone();
let script = self.script.clone();
let launch_after_configuration = script.launch_after_configuration;
tokio::spawn(fake_adapter(server, log, script));
let (r, w) = tokio::io::split(client);
Box::pin(async move {
Ok(AdapterIo {
launch_after_configuration,
reader: Box::new(r),
writer: Box::new(w),
child: None,
@@ -180,6 +188,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
let mut seq = 1000;
let mut pending_launch: Option<i64> = None;
let mut held: Vec<Value> = Vec::new();
let mut running = false;
macro_rules! send {
($v:expr) => {{
let mut v: Value = $v;
@@ -200,24 +209,37 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
continue;
}
match msg["command"].as_str().unwrap_or_default() {
"initialize" => ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
),
"initialize" => {
ok!(
msg,
json!({
"supportsConfigurationDoneRequest": true,
"supportsCompletionsRequest": true,
"supportsTerminateRequest": script.supports_terminate,
"supportsSetVariable": true,
"supportsFunctionBreakpoints": true,
"supportsDataBreakpoints": true,
"supportsStepInTargetsRequest": true,
"supportsRestartFrame": true,
"exceptionBreakpointFilters": [
{"filter": "raised", "label": "Raised"},
{"filter": "uncaught", "label": "Uncaught", "default": true, "description": "Uncaught exceptions"},
],
})
);
if script.gdb15_starts_at_launch {
// GDB 15 announces itself before it sees `launch` (which a client that
// defers it sends late).
send!(json!({"type": "event", "event": "initialized"}));
}
}
"launch" | "attach" => {
if script.gdb15_starts_at_launch {
// GDB 15 starts the program here and answers `launch` right away.
running = true;
ok!(msg, Value::Null);
continue;
}
if script.close_during_startup {
return;
}
@@ -289,6 +311,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
ok!(msg, json!({"value": format!("{}!", msg["arguments"]["value"].as_str().unwrap()), "type": "int"}))
}
"configurationDone" => {
if script.gdb15_starts_at_launch && running {
// GDB 15 answers `configurationDone` with `notStopped` while the program
// it started at `launch` is still running.
send!(
json!({"type": "response", "request_seq": msg["seq"], "command": "configurationDone", "success": false, "message": "notStopped"})
);
continue;
}
if !script.ignore_configuration_done {
ok!(msg, Value::Null);
}
@@ -510,6 +540,60 @@ async fn stop_cancels_pending_launch_and_attach() {
}
}
/// GDB 15 must receive breakpoints and configurationDone before launch.
#[tokio::test(start_paused = true)]
async fn gdb_15_launch_waits_for_the_configuration() {
for deferred in [false, true] {
let h =
harness(Script { gdb15_starts_at_launch: true, launch_after_configuration: deferred, ..Script::default() });
let result = h.start(LaunchConfig { adapter: "gdb".into(), ..config() }).await;
if deferred {
let id = result.expect("GDB 15 defers its launch past the configuration");
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("launch") > pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
} else {
let error = result.unwrap_err();
assert!(error.message.contains("configurationDone failed: notStopped"), "{}", error.message);
}
}
}
#[tokio::test(start_paused = true)]
async fn gdb_attach_keeps_the_normal_configuration_order() {
let h = harness(Script { launch_after_configuration: true, ..Script::default() });
let id = h.start(LaunchConfig { adapter: "gdb".into(), request: "attach".into(), ..config() }).await.unwrap();
let commands = h.commands();
let pos = |c: &str| commands.iter().position(|x| x == c).unwrap();
assert!(pos("attach") < pos("configurationDone"), "{commands:?}");
h.manager.stop(id).await.unwrap();
}
#[tokio::test(start_paused = true)]
async fn gdb_run_environment_overrides_preserve_inheritance() {
for explicit in [None, Some(r#"{"env":{"ONLY":"explicit"}}"#)] {
let h = harness(Script::default());
let id = h
.start(LaunchConfig { adapter: "gdb".into(), extra_json: explicit.map(str::to_owned), ..config() })
.await
.unwrap();
let session = h.manager.session(id).unwrap();
{
let log = h.log.lock();
let args = &log.iter().find(|m| m["command"] == "launch").unwrap()["arguments"];
if explicit.is_some() {
assert_eq!(args["env"], json!({"ONLY": "explicit"}));
assert!(session.spec.env.is_empty());
} else {
assert!(args.get("env").is_none(), "GDB must inherit its process environment");
assert!(session.spec.env.contains(&("A".into(), "1".into())));
}
}
h.manager.stop(id).await.unwrap();
}
}
#[tokio::test(start_paused = true)]
async fn startup_still_reports_adapter_failure_and_connection_closure() {
for request in ["launch", "attach"] {
@@ -517,7 +601,6 @@ async fn startup_still_reports_adapter_failure_and_connection_closure() {
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
assert!(h.manager.sessions(None).is_empty());
let h = harness(Script { close_during_startup: true, ..Script::default() });
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
@@ -1040,6 +1123,39 @@ async fn connects_to_running_debug_server_over_tcp() {
assert!(events.lock().iter().any(|e| matches!(e, IdeEvent::Debug { event: DebugEvent::Started { .. }, .. })));
}
#[tokio::test]
async fn delve_adapter_inherits_the_selected_go_environment() {
struct CaptureLauncher {
inner: FakeLauncher,
env: Arc<Mutex<Vec<(String, String)>>>,
}
impl AdapterLauncher for CaptureLauncher {
fn launch(&self, spec: AdapterSpec, reuse: Option<u16>) -> LaunchFuture {
*self.env.lock() = spec.env.clone();
self.inner.launch(spec, reuse)
}
}
let env = Arc::new(Mutex::new(Vec::new()));
let launcher =
CaptureLauncher { inner: FakeLauncher { log: Arc::default(), script: Script::default() }, env: env.clone() };
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(launcher));
let (owner, _) = recording_owner("main");
let config = LaunchConfig {
name: "Go".into(),
adapter: "delve".into(),
request: "launch".into(),
program: Some("/project".into()),
args: vec![],
cwd: None,
env: BTreeMap::from([("PATH".into(), "/chosen/go/bin".into()), ("GOROOT".into(), "/chosen/go".into())]),
extra_json: Some(json!({"mode":"debug"}).to_string()),
};
let id = manager.start(config, owner).await.unwrap();
assert!(env.lock().contains(&("PATH".into(), "/chosen/go/bin".into())));
assert!(env.lock().contains(&("GOROOT".into(), "/chosen/go".into())));
manager.stop(id).await.unwrap();
}
/// Go Remote / Python remote: the adapter runs on another machine (`dlv --headless`,
/// `debugpy --listen`); `debugServer: "host:port"` connects there instead of spawning one.
#[tokio::test]
@@ -1929,6 +2045,7 @@ async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
});
let (reader, writer) = tokio::io::split(client);
Ok(AdapterIo {
launch_after_configuration: false,
reader: Box::new(reader),
writer: Box::new(writer),
child: Some(child),
+20
View File
@@ -151,6 +151,16 @@ fn cases(dir: &Path) -> Vec<Case> {
field: None,
config: launch("lldb-dap", &c.join("main"), &c, serde_json::json!({"env": ["OPENIDEA_FIXTURE=1"]})),
},
// GDB's own DAP interpreter needs GDB 14+; older ones are skipped by `installed`.
Case {
adapter: "gdb",
source: c.join("main.c"),
loop_line: 13,
after_loop: 15,
function: "parse",
field: None,
config: launch("gdb", &c.join("main"), &c, serde_json::json!({})),
},
]);
out
}
@@ -230,6 +240,16 @@ fn installed(adapter: &str) -> bool {
"js-debug" => "js-debug-adapter",
"delve" => "dlv",
"java-debug" => return true,
// Only a GDB with the DAP interpreter (14+) counts as installed.
"gdb" => {
return std::process::Command::new("gdb").arg("--version").output().is_ok_and(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.next()
.and_then(adapter::parse_gdb_version)
.is_some_and(|(major, _)| major >= 14)
});
}
other => other,
};
adapter::resolve_binary(cmd, Path::new("/")).is_some()
+32
View File
@@ -38,6 +38,10 @@ pub enum Capability {
Clipboard,
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
Net(String),
/// Ask the user when the first request goes to an HTTPS (or loopback) host
/// the manifest does not list; each answer applies to one host. Plain
/// HTTP elsewhere always needs `net.http:<host>`.
NetAsk,
/// Unencrypted HTTP to one host.
NetHttp(String),
/// A local socket or named pipe on the project machine.
@@ -71,6 +75,7 @@ impl Capability {
| Self::DatabaseSources
| Self::VcsWrite
| Self::Net(_)
| Self::NetAsk
| Self::NetHttp(_)
| Self::Secrets
| Self::InputEvents
@@ -104,6 +109,10 @@ impl Capability {
"Copy to the clipboard from its commands, and read and write it from its web views".into()
}
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
Self::NetAsk => {
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones"
.into()
}
Self::NetHttp(host) => {
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
}
@@ -230,6 +239,7 @@ impl fmt::Display for Capability {
Self::InputEvents => f.write_str("events.input"),
Self::Clipboard => f.write_str("clipboard"),
Self::Net(h) => write!(f, "net:{h}"),
Self::NetAsk => f.write_str("net:ask"),
Self::NetHttp(h) => write!(f, "net.http:{h}"),
Self::Ipc(p) => write!(f, "ipc:{p}"),
Self::Process(b) => write!(f, "process:{b}"),
@@ -251,6 +261,9 @@ impl FromStr for Capability {
}
return match kind {
"net" | "net.http" => {
if kind == "net" && arg == "ask" {
return Ok(Self::NetAsk);
}
let host = arg.strip_prefix("*.").unwrap_or(arg);
let valid = !host.is_empty()
&& host.len() <= 253
@@ -451,6 +464,25 @@ mod command_tests {
}
}
#[cfg(test)]
mod net_ask_tests {
use super::*;
#[test]
fn net_ask_is_its_own_medium_risk_grant_covering_no_host() {
let cap: Capability = "net:ask".parse().unwrap();
assert_eq!(cap, Capability::NetAsk);
assert_eq!(cap.to_string(), "net:ask");
assert_eq!(cap.risk(), Risk::Medium);
assert!(!cap.auto_migrates());
// It asks; it does not itself connect anywhere or pre-approve a host.
assert!(!cap.covers(&"net:api.example.com".parse().unwrap()));
assert!(!cap.covers(&Capability::NetHttp("example.com".into())));
assert!(!"net:api.example.com".parse::<Capability>().unwrap().covers(&cap));
assert_ne!("net:ask.com".parse::<Capability>().unwrap(), Capability::NetAsk);
assert!("net:ask".parse::<Capability>().unwrap().describe().contains("host"));
}
}
#[cfg(test)]
mod database_tests {
use super::*;
+62
View File
@@ -53,6 +53,9 @@ pub struct Grant {
/// Granted capabilities (`project.read`, `net:x`, …).
#[serde(default)]
pub granted: BTreeSet<String>,
/// Exact hosts approved through `net:ask`, separate from manifest grants.
#[serde(default)]
pub net_hosts: BTreeSet<String>,
/// Optional capabilities the user declined (not asked again until re-confirmed).
#[serde(default)]
pub declined: BTreeSet<String>,
@@ -201,6 +204,9 @@ impl Ledger {
let Some(grant) = self.grants.get_mut(&req.subject) else {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
};
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
if !grant.confirmed {
return Decision::NeedsConfirmation { new: req.required.clone(), first_time: true };
}
@@ -225,6 +231,9 @@ impl Ledger {
binaries: BTreeMap<String, BinaryGrant>,
) {
let grant = self.grants.entry(req.subject.clone()).or_default();
if !req.required.iter().chain(&req.optional).any(|c| c == &Capability::NetAsk) {
grant.net_hosts.clear();
}
grant.plugin_id = req.plugin_id.to_owned();
grant.version = req.version.to_owned();
grant.package_hash = req.package_hash.to_owned();
@@ -252,6 +261,12 @@ impl Ledger {
grant.declined.remove(&s);
grant.granted.insert(s)
} else {
if let Capability::Net(host) = cap {
return grant.net_hosts.remove(host) | grant.granted.remove(&s);
}
if cap == &Capability::NetAsk {
grant.net_hosts.clear();
}
if let Capability::Process(_) = cap {
grant.binaries.remove(&s);
for binaries in grant.project_binaries.values_mut() {
@@ -262,6 +277,19 @@ impl Ledger {
}
}
/// Remembers one exact host without granting a manifest capability.
pub fn remember_net_host(&mut self, subject: &Subject, host: &str) -> bool {
let Some(grant) = self.grants.get_mut(subject) else { return false };
if !grant.confirmed || !grant.has(&Capability::NetAsk) || host.contains('*') {
return false;
}
grant.net_hosts.insert(host.to_owned())
}
pub fn forget_net_host(&mut self, subject: &Subject, host: &str) -> bool {
self.grants.get_mut(subject).is_some_and(|g| g.net_hosts.remove(host))
}
/// Records the program approved for a `process:` grant.
pub fn set_binary(&mut self, subject: &Subject, cap: &Capability, binary: BinaryGrant) {
if let Some(grant) = self.grants.get_mut(subject) {
@@ -510,6 +538,40 @@ mod tests {
assert!(matches!(l.decide(&r3), Decision::NeedsConfirmation { .. }));
}
#[test]
fn net_ask_hosts_persist_revoke_and_drop_with_the_capability() {
let tmp = tempfile::tempdir().unwrap();
let mut l = Ledger::load(tmp.path());
let r = req("1.0.0", "h", &["net:ask"], &[]);
l.confirm(&r, &[], BTreeMap::new());
let host = "api.example.com";
assert!(l.remember_net_host(&r.subject, host));
assert!(!l.remember_net_host(&r.subject, "*.example.com"));
assert!(!l.get(&r.subject).unwrap().has(&Capability::Net(host.into())));
l.save().unwrap();
l = Ledger::load(tmp.path());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
l.confirm(&r, &[], BTreeMap::new());
assert!(l.get(&r.subject).unwrap().net_hosts.contains(host));
assert!(l.set(&r.subject, &Capability::Net(host.into()), false));
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
for confirm in [false, true] {
l.remember_net_host(&r.subject, host);
// A newly declared host must not inherit an ask grant after net:ask is removed.
let update = req("2.0.0", "h2", &["project.read", "net:api.example.com"], &[]);
if confirm {
l.confirm(&update, &[], BTreeMap::new());
} else {
assert!(matches!(l.decide(&update), Decision::NeedsConfirmation { .. }));
}
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
l.remember_net_host(&r.subject, host);
l.set(&r.subject, &Capability::NetAsk, false);
assert!(l.get(&r.subject).unwrap().net_hosts.is_empty());
}
#[test]
fn migration_grants_low_risk_only() {
let tmp = tempfile::tempdir().unwrap();
+24
View File
@@ -338,6 +338,9 @@ impl Manifest {
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
self.needs_api(&format!("capability `{cap}`"), 3)?;
}
if matches!(cap, Capability::NetAsk) {
self.needs_api(&format!("capability `{cap}`"), 5)?;
}
}
if let Some(dark) = &self.icon_dark {
if self.icon.is_none() {
@@ -718,3 +721,24 @@ mod commands_api_tests {
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
}
}
#[cfg(test)]
mod net_ask_api_tests {
use super::*;
#[test]
fn net_ask_requires_api_1_5() {
for declaration in ["[capabilities]\nrequired = [\"net:ask\"]", "[capabilities]\noptional = [\"net:ask\"]"] {
for engine in ["^1.4", "^1.5"] {
let manifest = format!(
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
);
let parsed = Manifest::parse(&manifest);
if engine == "^1.5" {
parsed.unwrap();
} else {
assert!(parsed.unwrap_err().contains("new in plugin API 1.5"), "{engine}");
}
}
}
}
}
+1
View File
@@ -64,6 +64,7 @@ async fn debug_start_inner(
let settings = settings.clone();
config = host
.blocking(move || {
ide_sdk::go::prepare_debug_compiler(&mut config.env, &config.extra_json)?;
ide_sdk::go::configure_environment(&root, &settings, &mut config.env);
ide_sdk::go::apply_to_debug(
&mut config.env,
+52 -2
View File
@@ -922,12 +922,13 @@ fn plugin_file_edits(
use ide_plugin::FileOperation;
let confine = !a.operations.is_empty() || a.open.is_some();
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
let writable = |path: &Path| ide_plugin::resolve_writable_in_root(root, &path.to_string_lossy());
let mut files = Vec::new();
let mut created_with_text = Vec::new();
for operation in &a.operations {
match operation {
FileOperation::Create { path, text, overwrite } => {
let path = confined(path)?;
let path = writable(path)?;
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
if !text.is_empty() {
let start = Range::point(Pos::new(0, 0));
@@ -948,7 +949,7 @@ fn plugin_file_edits(
}
}
for (path, edits) in &a.edits {
let path = if confine { confined(path)? } else { path.clone() };
let path = if confine { writable(path)? } else { path.clone() };
if created_with_text.contains(&path) {
return Err("put the text of a created file in its `create`, not in an edit".into());
}
@@ -2228,6 +2229,55 @@ mod tests {
assert!(without_generated_helpers(Vec::new()).is_empty());
}
#[cfg(unix)]
#[test]
fn plugins_file_actions_confine_content_writes_and_preserve_link_entries() {
use ide_core::FileEdit;
use ide_plugin::{CodeAction, FileOperation};
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::fs::write(root.join("target.txt"), "inside").unwrap();
for (name, target) in [
("outside", outside.clone()),
("dangling", tmp.path().join("missing.txt")),
("inside", root.join("target.txt")),
] {
std::os::unix::fs::symlink(target, root.join(name)).unwrap();
}
let action = |operations, edits| CodeAction {
title: "Update files".into(),
kind: None,
preferred: false,
edits,
operations,
open: Some(root.join("target.txt")),
data: None,
file_action: true,
};
for name in ["outside", "dangling"] {
let create = FileOperation::Create { path: root.join(name), text: "text".into(), overwrite: true };
assert!(plugin_file_edits(&root, &action(vec![create], Vec::new())).is_err());
let edits = vec![(root.join(name), vec![TextEdit { range: Range::default(), new_text: "text".into() }])];
assert!(plugin_file_edits(&root, &action(Vec::new(), edits)).is_err());
}
let create = FileOperation::Create { path: root.join("inside"), text: "text".into(), overwrite: true };
let (files, _) = plugin_file_edits(&root, &action(vec![create], Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Create { path, .. } if **path == root.join("target.txt")));
let operations = vec![
FileOperation::Rename { from: root.join("outside"), to: root.join("dangling") },
FileOperation::Delete { path: root.join("outside") },
];
let (files, _) = plugin_file_edits(&root, &action(operations, Vec::new())).unwrap();
assert!(matches!(&files[0], FileEdit::Rename { from, to }
if **from == root.join("outside") && **to == root.join("dangling")));
assert!(matches!(&files[1], FileEdit::Delete { path, .. } if **path == root.join("outside")));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
}
#[test]
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
use ide_core::FileEdit;
+251 -15
View File
@@ -6,6 +6,7 @@ use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Capability;
use ide_extension::Package;
use ide_extension::ledger::{Ledger, Subject};
use ide_language::ProgramPin;
use ide_plugin::{
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
@@ -17,7 +18,7 @@ use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command as StdCommand, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Largest storage of one plugin (keys + values).
@@ -163,16 +164,10 @@ impl SecretStore for FileSecretStore {
None => map.remove(name),
};
let file = self.file(plugin);
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
std::fs::create_dir_all(&self.dir).map_err(|e| e.to_string())?;
ide_vfs::create_private_dir(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
replace_entry(&file, &bytes).map_err(|e| e.to_string())
}
}
@@ -501,6 +496,10 @@ pub(super) struct Shared {
pub(super) prompts: Mutex<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
pub(super) prompts_blocked: Mutex<BTreeSet<String>>,
/// One network question per plugin; refusals throttle all hosts for 30 seconds.
net_prompts: Mutex<HashMap<String, Option<Instant>>>,
pub net_hosts_session_only: AtomicBool,
session_net_hosts: Mutex<HashMap<String, BTreeSet<String>>>,
pub(super) next_prompt: AtomicU64,
pub(super) run_configurations: super::run_detectors::DynamicConfigurations,
pub(super) run_targets: Mutex<super::run_targets::Targets>,
@@ -524,6 +523,9 @@ pub(super) struct Shared {
pub secrets: RwLock<Arc<dyn SecretStore>>,
/// (plugin, `process:` capability) → the approved program.
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// The grant ledger, shared with the [`super::PluginHost`]: `net:ask`
/// looks up and records "always allow" hosts right where a request runs.
pub ledger: Arc<Mutex<Ledger>>,
pub project_pins: ProjectPins,
pub event_editors: RwLock<HashMap<String, ide_plugin::EditorContext>>,
/// Validated UI declarations (settings schemas, declared views).
@@ -548,6 +550,7 @@ impl Shared {
data_dir: PathBuf,
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
ledger: Arc<Mutex<Ledger>>,
) -> Arc<Self> {
Arc::new(Self {
ui,
@@ -556,6 +559,7 @@ impl Shared {
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
ledger,
project_pins: Arc::default(),
event_editors: RwLock::default(),
data_dir,
@@ -568,6 +572,9 @@ impl Shared {
process_shutdowns: Mutex::default(),
prompts: Mutex::default(),
prompts_blocked: Mutex::default(),
net_prompts: Mutex::default(),
net_hosts_session_only: AtomicBool::new(false),
session_net_hosts: Mutex::default(),
next_prompt: AtomicU64::new(1),
run_configurations: Arc::default(),
run_targets: Mutex::default(),
@@ -799,6 +806,8 @@ impl Shared {
self.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: plugin.into(), sets: Vec::new() });
}
self.withdraw_prompts(plugin);
self.net_prompts.lock().remove(plugin);
self.session_net_hosts.lock().remove(plugin);
let removed: Vec<(Option<String>, String)> = {
let mut contexts = self.contexts.lock();
let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect();
@@ -924,6 +933,15 @@ fn storage_io(e: std::io::Error) -> HostError {
HostError::Failed(format!("storage: {e}"))
}
fn replace_entry(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut tmp =
tempfile::Builder::new().prefix(".entry-").suffix(".tmp").tempfile_in(path.parent().expect("has parent"))?;
tmp.write_all(bytes)?;
tmp.persist(path).map_err(|e| e.error)?;
Ok(())
}
/// A key's file name: its hash, since keys may hold any character.
fn entry_name(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
@@ -945,8 +963,7 @@ fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(value.as_bytes());
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
replace_entry(&file, &bytes).map_err(storage_io)
}
/// The stored entries' files and the bytes of their keys and values.
@@ -983,6 +1000,9 @@ impl PluginApi {
/// older version into it.
fn storage(&self) -> Result<PathBuf, HostError> {
let dir = self.shared.storage_dir(self.id());
std::fs::create_dir_all(&self.shared.data_dir).map_err(storage_io)?;
ide_vfs::create_private_dir(dir.parent().expect("has parent")).map_err(failed)?;
ide_vfs::create_private_dir(&dir).map_err(failed)?;
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
match std::fs::read(&legacy) {
Ok(bytes) => {
@@ -2211,6 +2231,90 @@ impl HostApi for PluginApi {
})
}
/// The `net:ask` answer: a host the user allowed with "always" is
/// remembered in the ledger (no dialog again, background included); a new
/// host is asked about in `client`'s window when there is one. Dismissal
/// and refusal both deny and throttle further questions.
fn net_ask(
&self,
subject: &Subject,
client: Option<&str>,
host: &str,
cancel: &CancellationToken,
) -> Result<Option<bool>, HostError> {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if !self.pkg.manifest.declares(&Capability::NetAsk) {
return Ok(None);
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
{
let ledger = self.shared.ledger.lock();
if let Some(grant) = ledger.get(subject)
&& grant.confirmed
&& grant.has(&Capability::NetAsk)
&& grant.net_hosts.contains(&host)
{
return Ok(Some(true));
}
}
if self.shared.session_net_hosts.lock().get(self.id()).is_some_and(|hosts| hosts.contains(&host)) {
return Ok(Some(true));
}
let Some(client) = client else { return Ok(None) };
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Ok(None);
}
}
if self.shared.prompts_blocked.lock().contains(self.id()) {
return Ok(None);
}
{
let mut prompts = self.shared.net_prompts.lock();
if prompts.get(self.id()).is_some_and(|until| until.is_none_or(|until| until > Instant::now())) {
return Ok(None);
}
prompts.insert(self.id().to_owned(), None);
}
let session_only = self.shared.net_hosts_session_only.load(Ordering::Relaxed);
let answer = self.ask(Some(client), Prompt::NetPermission { host: host.clone(), session_only }, cancel);
let result = match answer {
Ok(Some(keys)) if keys == ["session"] || keys == ["always"] => {
let mut ledger = self.shared.ledger.lock();
if cancel.is_cancelled() {
Err(HostError::Cancelled)
} else if ledger.get(subject).is_some_and(|g| g.confirmed && g.has(&Capability::NetAsk)) {
if session_only || keys == ["session"] {
self.shared.session_net_hosts.lock().entry(self.id().into()).or_default().insert(host);
} else {
ledger.remember_net_host(subject, &host);
if let Err(e) = ledger.save() {
tracing::warn!("saving plugin grants: {e}");
}
}
drop(ledger);
self.shared.sink.emit(None, PluginEvent::CommandsChanged);
Ok(Some(true))
} else {
Ok(None)
}
}
Ok(Some(keys)) if keys == ["once"] => Ok(Some(false)),
Ok(_) => Ok(None),
Err(e) => Err(e),
};
let mut prompts = self.shared.net_prompts.lock();
if matches!(result, Ok(Some(_))) {
prompts.remove(self.id());
} else {
prompts.insert(self.id().to_owned(), Some(Instant::now() + Duration::from_secs(30)));
}
result
}
fn secret_is_set(&self, name: &str) -> bool {
self.shared.secrets.read().get(self.id(), name).is_some()
}
@@ -2682,7 +2786,9 @@ impl HostApi for PluginApi {
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
Err(e) => return Err(not_writable(real, e)),
};
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
check_write_path(real)?;
if private {
let bytes = ide_vfs::encode(text, &encoding, has_bom).map_err(|e| not_writable(real, e))?;
ide_vfs::write_atomic_private(real, &bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
@@ -2692,7 +2798,9 @@ impl HostApi for PluginApi {
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
if self.plugin_dir().is_ok_and(|dir| real.starts_with(dir)) {
let private = self.plugin_dir().is_ok_and(|dir| real.starts_with(dir));
check_write_path(real)?;
if private {
ide_vfs::write_atomic_private(real, bytes).map(drop).map_err(|e| not_writable(real, e))
} else {
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
@@ -2700,6 +2808,16 @@ impl HostApi for PluginApi {
}
}
/// Approved write paths are already resolved. Refuse substitutions since approval.
fn check_write_path(path: &Path) -> Result<(), HostError> {
let parent = path.parent().ok_or_else(|| HostError::Invalid("no directory to put it in".into()))?;
let checked = ide_plugin::resolve_writable_in_root(parent, &to_ipc(path)).map_err(HostError::Invalid)?;
if checked != path {
return Err(HostError::Invalid(format!("{}: file path changed before writing", path.display())));
}
Ok(())
}
/// A file that is not there, or cannot be read, as the plugin sees it.
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
match e.kind() {
@@ -3099,10 +3217,127 @@ mod tests {
)
.unwrap();
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new(), Arc::default());
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn net_questions_are_exclusive_throttled_and_cancelled() {
let dir = tempfile::tempdir().unwrap();
let mut api = api(dir.path(), Arc::default());
api.pkg = Package::new(
ide_extension::Manifest::parse(
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.5\"\n[capabilities]\nrequired = [\"net:ask\"]\n",
).unwrap(),
dir.path().to_path_buf(),
);
let subject = Subject::Installed(api.id().into());
api.shared.ledger.lock().confirm(
&ide_extension::ledger::Request {
subject: subject.clone(),
plugin_id: api.id(),
version: "1.0.0",
package_hash: "h",
required: vec![Capability::NetAsk],
optional: vec![],
},
&[],
BTreeMap::new(),
);
for answer in [Some(vec!["always".into(), "once".into()]), None] {
api.shared.net_prompts.lock().clear();
let cancel = CancellationToken::new();
std::thread::scope(|scope| {
let run = scope.spawn(|| api.net_ask(&subject, Some("main"), "::1", &cancel));
let deadline = Instant::now() + Duration::from_secs(5);
let id = loop {
if let Some(id) = api.shared.prompts.lock().keys().next().copied() {
break id;
}
assert!(Instant::now() < deadline);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(api.net_ask(&subject, Some("other"), "other.example", &cancel).unwrap(), None);
if let Some(answer) = answer {
assert!(api.shared.answer_prompt(id, Some(answer)));
assert_eq!(run.join().unwrap().unwrap(), None);
} else {
cancel.cancel();
assert!(matches!(run.join().unwrap(), Err(HostError::Cancelled)));
}
});
assert!(api.shared.prompts.lock().is_empty());
assert!(api.shared.ledger.lock().get(&subject).unwrap().net_hosts.is_empty());
assert_eq!(api.net_ask(&subject, Some("main"), "new.example", &CancellationToken::new()).unwrap(), None);
}
}
#[cfg(unix)]
#[test]
fn writes_recheck_approved_paths_after_symlink_substitution() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let root = tmp.path().join("project");
std::fs::create_dir_all(root.join("nested")).unwrap();
let path = ide_plugin::resolve_writable_in_root(&root, "nested/file.txt").unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
symlink(&outside, &path).unwrap();
assert!(api.write_text_file(&path, "text").is_err());
assert!(api.write_bytes_file(&path, b"bytes").is_err());
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
std::fs::remove_file(&path).unwrap();
std::fs::remove_dir(root.join("nested")).unwrap();
symlink(tmp.path(), root.join("nested")).unwrap();
assert!(api.write_text_file(&path, "text").is_err());
assert!(api.write_bytes_file(&path, b"bytes").is_err());
assert!(!tmp.path().join("file.txt").exists());
}
#[cfg(unix)]
#[test]
fn storage_and_secrets_do_not_write_through_temporary_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
let dir = api.storage().unwrap();
let entry = dir.join(entry_name("key"));
symlink(&outside, entry.with_extension("tmp")).unwrap();
symlink(&outside, &entry).unwrap();
api.storage_set("key", Some("value".into())).unwrap();
assert_eq!(api.storage_get("key").unwrap().as_deref(), Some("value"));
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
secrets.set("acme.lint", "key", Some("before")).unwrap();
symlink(&outside, secrets.file("acme.lint").with_extension("tmp")).unwrap();
secrets.set("acme.lint", "key", Some("after")).unwrap();
assert_eq!(secrets.get("acme.lint", "key").as_deref(), Some("after"));
assert_eq!(std::fs::read_to_string(&outside).unwrap(), "unchanged");
}
#[cfg(unix)]
#[test]
fn storage_and_secrets_refuse_symlinked_private_directories() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let dir = api.storage().unwrap();
std::fs::remove_dir(&dir).unwrap();
let outside = tmp.path().join("outside");
std::fs::create_dir(&outside).unwrap();
std::os::unix::fs::symlink(&outside, &dir).unwrap();
assert!(api.storage_set("key", Some("value".into())).is_err());
let secrets = FileSecretStore { dir: tmp.path().join("secrets") };
std::fs::create_dir(&secrets.dir).unwrap();
std::os::unix::fs::symlink(&outside, secrets.dir.join("acme.lint")).unwrap();
assert!(secrets.set("acme.lint", "key", Some("value")).is_err());
assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0);
}
#[cfg(windows)]
#[test]
fn private_directory_matches_resolved_write_paths() {
@@ -3716,6 +3951,7 @@ mod workbench_tests {
root.join(".data"),
Arc::default(),
ide_extension::UiStore::new(),
Arc::default(),
);
*shared.workspace.write() =
Some(Arc::new(Workspace { root: root.into(), trusted, text: text.map(str::to_owned) }));
+3 -1
View File
@@ -992,7 +992,9 @@ mod tests {
let put = |product: &str, file: &str, seconds| {
let path = root.join(product).join(file);
write(&path, b"<application/>");
std::fs::File::open(path)
std::fs::File::options()
.write(true)
.open(path)
.unwrap()
.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds))
.unwrap();
+43 -7
View File
@@ -380,7 +380,7 @@ pub struct PluginHost {
diagnostic_projects: Mutex<Vec<diagnostics::ProjectState>>,
/// Validated UI declarations of installed packages (start-up reuses them).
index: Arc<ContributionIndex>,
ledger: Mutex<Ledger>,
ledger: Arc<Mutex<Ledger>>,
plugins: RwLock<BTreeMap<String, Arc<LoadedPlugin>>>,
state: Mutex<PersistedState>,
/// What every plugin's [`api::PluginApi`] shares: sink, registry,
@@ -452,7 +452,9 @@ impl PluginHost {
languages.set_process_policy(Arc::new(move |plugin, cap| {
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
}));
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
let ledger = Arc::new(Mutex::new(Ledger::load(&plugins_dir)));
let shared =
api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone(), ledger.clone());
let official_keys = market::built_in_official_keys();
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
let host = Self {
@@ -463,7 +465,7 @@ impl PluginHost {
updates: Mutex::default(),
project_policy: RwLock::new(Arc::new(|_| false)),
engine: PluginEngine::new().map_err(IdeError::internal)?,
ledger: Mutex::new(Ledger::load(&plugins_dir)),
ledger,
plugins_dir,
sink,
limits,
@@ -806,6 +808,11 @@ impl PluginHost {
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
}
/// Keeps hosts allowed through `net:ask` in memory for this host's lifetime.
pub fn set_net_hosts_session_only(&self) {
self.shared.net_hosts_session_only.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// The UI language windows run in, as `locale()` reports it to plugin code.
pub fn set_ui_locale(&self, tag: &str) {
*self.shared.locale.write() = tag.to_owned();
@@ -1079,19 +1086,39 @@ impl PluginHost {
self.info_of(id)
}
/// Grants or revokes one capability (Settings › Plugins › Permissions).
/// Revokes one exact host remembered through `net:ask`.
pub fn revoke_net_host(&self, id: &str, host: &str) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
{
let mut ledger = self.ledger.lock();
if !ledger.forget_net_host(&plugin.subject(), host) {
return Err(IdeError::invalid("host is not remembered for this plugin"));
}
self.save_ledger(&ledger);
}
self.stop(&plugin);
if !self.state.lock().disabled.contains(id) {
self.start(&plugin);
}
self.changed();
self.info_of(id)
}
/// Grants or revokes one declared capability (Settings › Plugins › Permissions).
/// Revoking a required one stops the plugin until consent is given again.
pub fn set_capability(&self, id: &str, capability: &str, granted: bool) -> IdeResult<PluginInfo> {
let _guard = self.lifecycle.lock();
let plugin = self.get(id)?;
self.check_not_uninstalling(&plugin)?;
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
}
{
let mut ledger = self.ledger.lock();
let subject = plugin.subject();
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
return Err(IdeError::invalid(format!("plugin {id:?} does not declare {cap}")));
}
if ledger.get(&subject).is_none() {
return Err(IdeError::invalid(format!("plugin {id:?} has not been confirmed yet")));
}
@@ -2537,6 +2564,7 @@ impl PluginHost {
plugin_id: plugin.id().to_owned(),
version: plugin.manifest().version.clone(),
granted,
subject: plugin.subject(),
host: api,
limits: self.limits,
};
@@ -2914,6 +2942,13 @@ impl PluginHost {
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
}
}
// Hosts remembered through `net:ask` are grants of their own: listed
// separately so the user can revoke them one by one.
let net_hosts = grant
.as_ref()
.filter(|g| g.confirmed && g.has(&Capability::NetAsk) && m.declares(&Capability::NetAsk))
.map(|g| g.net_hosts.iter().cloned().collect())
.unwrap_or_default();
PluginInfo {
id: m.id.clone(),
name: m.name.clone(),
@@ -2930,6 +2965,7 @@ impl PluginHost {
error: meta.error.clone(),
has_code: m.wasm.is_some(),
capabilities,
net_hosts,
contributions: self.registry.contributions(&m.id),
activation: m.activation.iter().map(ToString::to_string).collect(),
warnings: meta.warnings.clone(),
@@ -292,6 +292,7 @@ impl RemotePlugins {
}
};
let host = Arc::new(PluginHost::new(data_dir.join(PLUGINS_DIR), Arc::new(sink), Limits::default())?);
host.set_net_hosts_session_only();
projects.set_plugin_host(&host);
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(projects)))));
host.set_secret_store(Arc::new(ClientSecrets { peer: peer.clone(), runtime: projects.runtime.clone() }));
+3
View File
@@ -72,6 +72,9 @@ pub struct PluginInfo {
pub has_code: bool,
/// Declared capabilities with risk and grant state.
pub capabilities: Vec<CapabilityInfo>,
/// HTTPS hosts the user allowed through `net:ask` ("always allow"),
/// none of them declared by the manifest; each can be revoked.
pub net_hosts: Vec<String>,
/// What it contributes while live (point + label).
pub contributions: Vec<ContributionInfo>,
/// `activation` events of the manifest.
+1
View File
@@ -420,6 +420,7 @@ pub fn projectless_command_allowed(cmd: &str) -> bool {
| "plugin_set_enabled"
| "plugin_grant"
| "plugin_set_capability"
| "plugin_revoke_net_host"
| "plugin_choose_program"
| "plugin_program"
| "plugin_roll_back"
+107 -53
View File
@@ -59,16 +59,15 @@ struct Checks {
hover: (At, &'static str),
/// Completion at the end of the typed line offers this item.
completion: (Typed, &'static str),
/// The typed line is reported with this diagnostic; the quick fix with this title makes the
/// file contain the last text.
/// The typed line is reported with a diagnostic, and its quick fix applies.
quick_fix: QuickFix,
/// With the word selected, this intention is offered with an edit or command.
intention: (At, &'static str),
intention: Option<(At, &'static str)>,
/// Formatting turns the typed line into the expected one.
format: (Typed, &'static str),
indent: Indent,
/// Renaming the symbol to the new name edits these files.
rename: (At, &'static str, &'static [&'static str]),
rename: Option<(At, &'static str, &'static [&'static str])>,
/// Parameter info in a call being typed: a signature naming this parameter.
signature: (Typed, &'static str),
/// The Structure view of a file: these symbols, at any depth.
@@ -104,8 +103,8 @@ enum Imports {
struct QuickFix {
typed: Typed,
diagnostic: &'static str,
title: &'static str,
result: &'static str,
/// The quick fix with this title makes the file contain this text.
fix: Option<(&'static str, &'static str)>,
}
/// The editor's indentation, sent with a format request.
@@ -135,7 +134,7 @@ fn copy_dir(from: &Path, to: &Path) {
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let name = entry.file_name();
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv")) {
if matches!(name.to_str(), Some("node_modules" | "target" | "build" | ".gradle" | ".venv" | "vendor")) {
continue;
}
let target = to.join(&name);
@@ -392,19 +391,21 @@ impl Smoke {
.unwrap_or_else(|e| self.fail(check, format_args!("resolving {title:?}: {e}")))
}
async fn quick_fix(&self, fix: QuickFix) {
let (doc, _, before) = self.type_line(fix.typed).await;
assert!(!before.contains(fix.result), "{fix:?}: the file already contains the result");
let range = self.diagnostic(fix.typed.file, fix.diagnostic).await;
let action = self.action("quick fix", doc, range, fix.title).await;
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
.await
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {:?}: {e}", fix.title)));
self.retry("quick fix", Duration::from_secs(10), || async {
let text = self.doc(fix.typed.file).await.1;
if text.contains(fix.result) { Ok(()) } else { Err(text) }
})
.await;
async fn quick_fix(&self, q: QuickFix) {
let (doc, _, before) = self.type_line(q.typed).await;
let range = self.diagnostic(q.typed.file, q.diagnostic).await;
if let Some((title, result)) = q.fix {
assert!(!before.contains(result), "{q:?}: the file already contains the result");
let action = self.action("quick fix", doc, range, title).await;
ops::lsp::lsp_execute_code_action(&self.host, WINDOW, RequestId(4), doc, action, vec![])
.await
.unwrap_or_else(|e| self.fail("quick fix", format_args!("applying {title:?}: {e}")));
self.retry("quick fix", Duration::from_secs(10), || async {
let text = self.doc(q.typed.file).await.1;
if text.contains(result) { Ok(()) } else { Err(text) }
})
.await;
}
self.restore(doc, &before).await;
}
@@ -619,7 +620,9 @@ impl Smoke {
self.hover(c.hover.0, c.hover.1).await;
self.completion(c.completion.0, c.completion.1).await;
self.quick_fix(c.quick_fix).await;
self.intention(c.intention.0, c.intention.1).await;
if let Some((on, title)) = c.intention {
self.intention(on, title).await;
}
self.format(c.format.0, c.indent, c.format.1).await;
self.signature(c.signature.0, c.signature.1).await;
self.outline(c.outline.0, c.outline.1).await;
@@ -627,7 +630,9 @@ impl Smoke {
if let Some((import, expect)) = c.organize_imports {
self.organize_imports(import, expect).await;
}
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
if let Some((symbol, to, files)) = c.rename {
self.rename(symbol, to, files).await;
}
// Last: it moves a file the other checks use.
if let Some((from, to, referencing, result)) = c.file_move {
self.file_move(from, to, referencing, result).await;
@@ -656,21 +661,20 @@ const TYPESCRIPT: Checks = Checks {
quick_fix: QuickFix {
typed: typed("src/index.ts", "const total = cart.total();", " console.log(addMoney(total, total));"),
diagnostic: "Cannot find name 'addMoney'",
title: "import from \"./money.ts\"",
result: "import { addMoney, cents, formatMoney } from \"./money.ts\";",
fix: Some(("import from \"./money.ts\"", "import { addMoney, cents, formatMoney } from \"./money.ts\";")),
},
intention: (at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant"),
intention: Some((at("src/index.ts", "const total = cart.total();", "cart.total()"), "Extract to constant")),
format: (
typed("src/index.ts", "const total = cart.total();", " const shown={total,count:cart.count}"),
" const shown = { total, count: cart.count }",
),
indent: Indent::Spaces(2),
rename: (
rename: Some((
// From a usage in another file: the declaration is renamed too, not aliased in the import.
at("src/index.ts", "Total: ${formatMoney(total)}", "formatMoney"),
"formatPrice",
&["src/index.ts", "src/money.ts"],
),
)),
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
outline: ("src/cart.ts", &["Cart", "add", "total"]),
auto_import: AutoImport {
@@ -713,21 +717,20 @@ const PYTHON: Checks = Checks {
quick_fix: QuickFix {
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " print(Task)"),
diagnostic: "\"Task\" is not defined",
title: "from .models import Task",
result: "from .models import Priority, Task, TaskList",
fix: Some(("from .models import Task", "from .models import Priority, Task, TaskList")),
},
// basedpyright has no refactorings; Ruff's fixes are the Python intentions.
intention: (at("src/tasks/models.py", "import os", "os"), "Remove unused import"),
intention: Some((at("src/tasks/models.py", "import os", "os"), "Remove unused import")),
format: (
typed("src/tasks/cli.py", "tasks = build_demo()", " shown2 = {'a':1,'b': 2}"),
" shown2 = {\"a\": 1, \"b\": 2}",
),
indent: Indent::Spaces(4),
rename: (
rename: Some((
at("src/tasks/models.py", "class TaskList:", "TaskList"),
"TaskBoard",
&["src/tasks/cli.py", "src/tasks/models.py"],
),
)),
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
auto_import: AutoImport {
@@ -768,16 +771,22 @@ const RUST: Checks = Checks {
" let _blank = parser::ParseError { line: 1 };",
),
diagnostic: "missing structure fields",
title: "Fill struct fields",
result: "ParseError { line: 1, message",
fix: Some(("Fill struct fields", "ParseError { line: 1, message")),
},
intention: (at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"), "Extract into variable"),
intention: Some((
at("src/main.rs", "match parser::parse(SAMPLE)", "parser::parse(SAMPLE)"),
"Extract into variable",
)),
format: (
typed("src/main.rs", "let mut inventory = Inventory::new();", " let _spaced=( 1,2 );"),
" let _spaced = (1, 2);",
),
indent: Indent::Spaces(4),
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
rename: Some((
at("src/parser.rs", "pub fn parse(text: &str)", "parse"),
"parse_all",
&["src/main.rs", "src/parser.rs"],
)),
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
auto_import: AutoImport {
@@ -816,17 +825,16 @@ const GO: Checks = Checks {
quick_fix: QuickFix {
typed: typed("cmd/server/main.go", "s := store.New()", "\tfmt.Println(s)"),
diagnostic: "undefined: fmt",
title: "\"fmt\"",
result: "\t\"fmt\"",
fix: Some(("\"fmt\"", "\t\"fmt\"")),
},
intention: (at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract"),
intention: Some((at("cmd/server/main.go", "s := store.New()", "store.New()"), "Extract")),
format: (typed("cmd/server/main.go", "s := store.New()", "\t_ = []int{1,2}"), "\t_ = []int{1, 2}"),
indent: Indent::Tabs,
rename: (
rename: Some((
at("internal/store/store.go", "func New() *Store", "New"),
"Open",
&["cmd/server/main.go", "internal/store/store.go"],
),
)),
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
auto_import: AutoImport {
@@ -872,13 +880,12 @@ const JAVA: Checks = Checks {
" List<Order> all = book.byCustomer(\"alice\");",
),
diagnostic: "List cannot be resolved to a type",
title: "Import 'List' (java.util)",
result: "import java.util.List;",
fix: Some(("Import 'List' (java.util)", "import java.util.List;")),
},
intention: (
intention: Some((
at("src/main/java/com/example/orders/App.java", "long revenue = book.revenueCents();", "book.revenueCents()"),
"Extract to local variable",
),
)),
format: (
typed(
"src/main/java/com/example/orders/App.java",
@@ -888,11 +895,11 @@ const JAVA: Checks = Checks {
" long doubled = revenue * 2;",
),
indent: Indent::Spaces(4),
rename: (
rename: Some((
at("src/main/java/com/example/orders/OrderBook.java", "public Order place(", "place"),
"submit",
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
),
)),
signature: (
typed(
"src/main/java/com/example/orders/App.java",
@@ -959,23 +966,22 @@ const KOTLIN: Checks = Checks {
" val stamp = LocalDateTime.now()",
),
diagnostic: "Unresolved reference",
title: "Import",
result: "import java.time.LocalDateTime",
fix: Some(("Import", "import java.time.LocalDateTime")),
},
intention: (
intention: Some((
at("src/main/kotlin/com/example/library/Library.kt", "fun giveBack(isbn: String): Boolean", "giveBack"),
"block body",
),
)),
format: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " val pair=listOf(1,2)"),
" val pair = listOf(1, 2)",
),
indent: Indent::Spaces(4),
rename: (
rename: Some((
at("src/main/kotlin/com/example/library/Library.kt", "fun search(query: String)", "search"),
"find",
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
),
)),
signature: (
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
"isbn",
@@ -1003,3 +1009,51 @@ const KOTLIN: Checks = Checks {
async fn kotlin() {
smoke("kotlin", KOTLIN).await;
}
const PHP: Checks = Checks {
fixture: "php-app",
files: &[],
warm_up: Duration::from_secs(60),
definition: (
at("bin/quote.php", "$customs = new ThresholdCustoms", "ThresholdCustoms"),
at("src/Shipping/ThresholdCustoms.php", "final class ThresholdCustoms", "ThresholdCustoms"),
),
references: (
at("src/Shipping/Parcel.php", "public function billableGrams", "billableGrams"),
&["bin/quote.php", "src/Shipping/Parcel.php", "src/Shipping/Shipment.php", "src/Shipping/Tariff.php"],
),
hover: (
at("bin/quote.php", "$parcel->billableGrams($shipment->route)", "billableGrams"),
"public function billableGrams(Route $route): int",
),
completion: (typed("bin/quote.php", "$freightCents += $cents;", " $parcel->"), "billableGrams"),
// Intelephense declares no code actions and no rename without a licence key.
quick_fix: QuickFix {
typed: typed("bin/quote.php", "$freightCents += $cents;", " $tariff->weigh($parcel);"),
diagnostic: "Undefined method 'weigh'",
fix: None,
},
intention: None,
format: (
typed("bin/quote.php", "$freightCents += $cents;", " $pair=[1,2 ,3];"),
" $pair = [1, 2, 3];",
),
indent: Indent::Spaces(4),
rename: None,
signature: (typed("bin/quote.php", "$freightCents += $cents;", " $tariff->freightCents("), "parcel"),
outline: ("src/Shipping/Shipment.php", &["Shipment", "add", "heaviest", "byStore"]),
auto_import: AutoImport {
typed: typed("bin/quote.php", "$freightCents += $cents;", " $status = ParcelSta"),
label: "ParcelStatus",
result: "use App\\Shipping\\ParcelStatus;",
},
organize_imports: None,
// Intelephense has no `workspace/willRenameFiles`.
file_move: None,
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "language smoke: needs intelephense"]
async fn php() {
smoke("php", PHP).await;
}
+3 -3
View File
@@ -250,7 +250,7 @@ async fn plugin_run_targets_register_prompt_route_revalidate_and_withdraw() {
f.host.set_plugin_host(&plugins);
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&package, &Consent::all()).unwrap();
let tool = f._tmp.path().join("wrapper");
let tool = f.root.with_file_name("wrapper");
std::fs::write(&tool, "#!/bin/sh\nprintf 'cwd=%s\\n' \"$PWD\"\nprintf '<%s>\\n' \"$@\"\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let invocation =
@@ -370,7 +370,7 @@ async fn run_target_api15_overrides_and_frozen_api14_registration() {
let engine = PluginEngine::new().unwrap();
let legacy = engine.compile(&fixtures.join("run-target-api14/dist/plugin.wasm")).unwrap();
assert_eq!(engine.check_component(&legacy).unwrap().unwrap().to_string(), "1.4.0");
let tool = f._tmp.path().join("wrapper");
let tool = f.root.with_file_name("wrapper");
std::fs::write(&tool, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
let invocation =
@@ -456,7 +456,7 @@ async fn run_target_validation_gates_launches_and_preserves_selection() {
f.host.set_plugin_host(&plugins);
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
plugins.install_from_dir(&fixture, &Consent::all()).unwrap();
let tool = f._tmp.path().join("wrapper");
let tool = f.root.with_file_name("wrapper");
std::fs::write(&tool, "#!/bin/sh\ntouch spawned\n").unwrap();
std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
plugins.choose_program("execution", "process:execution-tool", Some(&f.root), Some(&tool)).unwrap();
+176
View File
@@ -542,6 +542,30 @@ fn project_write_stays_inside_the_project() {
assert_eq!((info.status, info.stats.crashes, info.stats.errors), (PluginStatus::Active, 0, 0), "{info:?}");
}
#[cfg(unix)]
#[test]
fn project_writes_confine_symlink_targets() {
use std::os::unix::fs::symlink;
let f = fixture(Limits::default());
f.install(&misbehave_with(f._tmp.path(), "misbehave-symlink-writer", "project.write"));
let outside = f._tmp.path().join("secret.txt");
let missing = f._tmp.path().join("missing.txt");
symlink(&outside, f.project.join("outside.txt")).unwrap();
symlink(&missing, f.project.join("dangling.txt")).unwrap();
for name in ["outside.txt", "dangling.txt"] {
let error = f.run("misbehave", "projwrite", Some(name)).unwrap_err();
assert!(error.contains("access outside the project root is not allowed"), "{error}");
}
assert_eq!(std::fs::read_to_string(outside).unwrap(), "top secret");
assert!(!missing.exists());
for (name, target) in [("inside.txt", "src/notes.txt"), ("new.txt", "new/deep.txt")] {
symlink(target, f.project.join(name)).unwrap();
f.run("misbehave", "projwrite", Some(name)).unwrap();
assert_eq!(std::fs::read_to_string(f.project.join(target)).unwrap(), "written inside the project\n");
assert!(std::fs::symlink_metadata(f.project.join(name)).unwrap().is_symlink());
}
}
/// `fs.write` replaces files the grant covers and nothing else, through the
/// real component.
#[test]
@@ -5729,6 +5753,158 @@ fn api14_denies_new_privileges_and_requires_an_invoking_window() {
assert_eq!(f.host.declared_secret("api14", "token"), None);
}
/// `net:ask`: a first request to a host outside the manifest asks the user
/// from an invoking window ("once" allows this request, "always" remembers
/// the host, refusal and dismissal deny). Background calls are denied without
/// a dialog, plain HTTP still needs `net.http:<host>`, and a remembered host
/// is listed in the permissions, where it can be revoked.
#[test]
fn net_ask_asks_once_remembers_always_and_revokes() {
use ide_plugin::Prompt;
let f = fixture(Limits::default());
f.host.install_from_dir(&sample("api14"), &Consent::default()).unwrap();
let (port, seen) = http_server("relay ok", |_| Duration::ZERO);
let url = format!("http://127.0.0.1:{port}/");
// Merely declaring an optional capability is not enough: the user must grant it.
let err = f.run("api14", "fetch", Some(&url)).unwrap_err();
assert!(err.contains("not granted"), "{err}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
f.host.set_capability("api14", "net:ask", true).unwrap();
// Plain HTTP elsewhere stays out of the ask flow entirely.
let err = f.run("api14", "fetch", Some("http://example.com/")).unwrap_err();
assert!(err.contains("net.http:example.com") && err.contains("not granted"), "{err}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
// Background (no invoking window): denied like before, no dialog.
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
assert!(err.message.contains("net:127.0.0.1") && err.message.contains("not granted"), "{err:?}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert!(seen.lock().is_empty(), "nothing connected");
// From a window: the question names the full host. "Allow once" lets
// this request through and remembers nothing.
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["once"]));
assert_eq!(result.unwrap().as_deref(), Some("relay ok"));
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::<String>::new());
assert_eq!(seen.lock().len(), 1);
// The next request asks again; "Always allow" remembers the host.
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&["always"]));
assert_eq!(result.unwrap().as_deref(), Some("relay ok"));
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, ["127.0.0.1".to_owned()]);
// Remembered: connecting without a dialog from a window and in the background.
let result = f.host.run_command("api14", "fetch", Some(&url), f.inv()).map_err(|e| e.message).unwrap();
assert_eq!(result.as_deref(), Some("relay ok"));
let result =
f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).map_err(|e| e.message).unwrap();
assert_eq!(result.as_deref(), Some("relay ok"));
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert_eq!(seen.lock().len(), 4);
// The permissions list the host; revoking it asks again, and refusing denies.
f.host.revoke_net_host("api14", "127.0.0.1").unwrap();
assert_eq!(f.host.info_of("api14").unwrap().net_hosts, Vec::<String>::new());
let (result, prompt) = net_ask_run_and_answer(&f, &url, None);
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: false });
let err = result.unwrap_err();
assert!(err.contains("not granted"), "{err}");
// Refusal throttles even a different host, without making any connection.
let err = f.run("api14", "fetch", Some("https://other.example/")).unwrap_err();
assert!(err.contains("not granted"), "{err}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert_eq!(seen.lock().len(), 4);
f.host.uninstall("api14").unwrap();
let ledger = ide_extension::ledger::Ledger::load(&f.plugins_dir);
assert!(ledger.get(&ide_extension::ledger::Subject::Installed("api14".into())).is_none());
}
#[test]
fn net_ask_session_hosts_are_ephemeral_and_cleared_with_plugin_grants() {
use ide_extension::ledger::{Ledger, Subject};
use ide_plugin::Prompt;
let f = fixture(Limits::default());
f.host.set_net_hosts_session_only();
f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap();
let (port, seen) = http_server("session ok", |_| Duration::ZERO);
let url = format!("http://127.0.0.1:{port}/");
let subject = Subject::Installed("api14".into());
for (answer, reset) in [("session", "disable"), ("always", "revoke"), ("session", "uninstall")] {
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
assert!(err.message.contains("not granted"), "{err:?}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
let (result, prompt) = net_ask_run_and_answer(&f, &url, Some(&[answer]));
assert_eq!(prompt, Prompt::NetPermission { host: "127.0.0.1".into(), session_only: true });
assert_eq!(result.unwrap().as_deref(), Some("session ok"));
for invocation in [f.inv(), Invocation::default()] {
let result = f.host.run_command("api14", "fetch", Some(&url), invocation).unwrap();
assert_eq!(result.as_deref(), Some("session ok"));
}
let err =
f.host.run_command("api14", "fetch", Some("https://other.example/"), Invocation::default()).unwrap_err();
assert!(err.message.contains("not granted"), "{err:?}");
assert!(!f.take_events().iter().any(|(_, e)| matches!(e, PluginEvent::PromptRequested { .. })));
assert!(f.host.info_of("api14").unwrap().net_hosts.is_empty());
assert!(Ledger::load(&f.plugins_dir).get(&subject).unwrap().net_hosts.is_empty());
match reset {
"disable" => {
f.host.set_enabled("api14", false).unwrap();
f.host.set_enabled("api14", true).unwrap();
}
"revoke" => {
f.host.set_capability("api14", "net:ask", false).unwrap();
f.host.set_capability("api14", "net:ask", true).unwrap();
}
"uninstall" => {
f.host.uninstall("api14").unwrap();
assert!(Ledger::load(&f.plugins_dir).get(&subject).is_none());
f.host.install_from_dir(&sample("api14"), &Consent::all()).unwrap();
}
_ => unreachable!(),
}
}
let err = f.host.run_command("api14", "fetch", Some(&url), Invocation::default()).unwrap_err();
assert!(err.message.contains("not granted"), "{err:?}");
assert_eq!(seen.lock().len(), 9);
}
/// Runs api14's `fetch` in its window; once the `net:ask` question arrives,
/// answers it (`None`: dismisses). Returns the command's result and the prompt.
fn net_ask_run_and_answer(
f: &Fixture,
url: &str,
answer: Option<&[&str]>,
) -> (Result<Option<String>, String>, ide_plugin::Prompt) {
std::thread::scope(|scope| {
let running = scope.spawn(|| f.run("api14", "fetch", Some(url)));
let deadline = Instant::now() + Duration::from_secs(10);
let (client, prompt_id, prompt) = loop {
let asked = f.take_events().into_iter().find_map(|(client, e)| match e {
PluginEvent::PromptRequested { prompt_id, prompt, .. } => Some((client, prompt_id, prompt)),
_ => None,
});
if let Some(asked) = asked {
break asked;
}
assert!(Instant::now() < deadline && !running.is_finished(), "the fetch never asked");
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(client.as_deref(), Some("main"), "asks in the window that ran the command");
let answer = answer.map(|a| a.iter().map(|s| (*s).to_owned()).collect());
assert!(f.host.answer_prompt(&prompt_id, answer));
assert!(!f.host.answer_prompt(&prompt_id, None), "an answer counts once");
(running.join().unwrap(), prompt)
})
}
#[test]
fn api14_window_status_overrides_fall_back_and_are_withdrawn() {
let f = fixture(Limits::default());
+7 -7
View File
@@ -616,16 +616,16 @@ async fn api14_stage3_remote_terminal_pins_and_exit_events_stay_on_the_execution
assert_eq!(settings["run"]["defaultTarget"], "");
let config = json!({"id":"wrapped-php", "name":"Wrapped PHP", "kind":"php", "command":"php", "args":["x.php", "literal ; $(touch bad)"], "env":{}, "detected":false, "isTest":false, "target":id});
invoke(&peer, "run_start", json!({"config":config, "onOutput":{"$channel":38}})).await.unwrap();
until("remote wrapped run", 5, || project.join("wrapped.txt").exists()).await;
assert_eq!(
std::fs::read_to_string(project.join("wrapped.txt")).unwrap(),
"<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n"
);
let wrapped = "<wrapped>\n<--cwd=/container>\n<php>\n<x.php>\n<literal ; $(touch bad)>\n";
until("remote wrapped run", 5, || std::fs::read_to_string(project.join("wrapped.txt")).is_ok_and(|s| s == wrapped))
.await;
assert!(!project.join("bad").exists());
let composer = json!({"id":"composer", "name":"Composer", "kind":"shell", "command":"composer", "args":["install"], "env":{}, "detected":false, "isTest":false, "target":id});
invoke(&peer, "run_start", json!({"config":composer, "onOutput":{"$channel":39}})).await.unwrap();
until("remote composer override", 5, || project.join("composer.txt").exists()).await;
assert_eq!(std::fs::read_to_string(project.join("composer.txt")).unwrap(), "<composer>\n<install>\n");
until("remote composer override", 5, || {
std::fs::read_to_string(project.join("composer.txt")).is_ok_and(|s| s == "<composer>\n<install>\n")
})
.await;
invoke(
&peer,
"plugin_run_command",
+4 -1
View File
@@ -20,7 +20,10 @@ pub use runtime::{
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
component_api, host_api,
};
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real};
pub use sandbox::{
MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root, resolve_real,
resolve_writable_in_root,
};
pub use types::*;
pub use wasmtime::component::Component;
+26
View File
@@ -419,6 +419,21 @@ pub trait HostApi: Send + Sync {
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError>;
/// Performs `request` (its host is granted), filling in secret placeholders.
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError>;
/// The `net:ask` answer for a request to HTTPS host `host` of this plugin
/// (`subject`): `Some(true)` allowed and remembered (a host the user
/// already allowed connects without asking again, also in the background),
/// `Some(false)` allowed for this request only, `None` refused — also when
/// nobody could be asked (`client` is `None` outside commands and native view actions).
fn net_ask(
&self,
subject: &ide_extension::ledger::Subject,
client: Option<&str>,
host: &str,
cancel: &CancellationToken,
) -> Result<Option<bool>, HostError> {
let _ = (subject, client, host, cancel);
Ok(None)
}
fn secret_is_set(&self, name: &str) -> bool;
/// Stores or deletes a secret declared by this plugin; never returns its value.
@@ -796,10 +811,12 @@ pub struct StoreData {
stderr_seen: usize,
pub(crate) plugin_id: String,
pub(crate) granted: Vec<Capability>,
pub(crate) subject: ide_extension::ledger::Subject,
pub(crate) host: Arc<dyn HostApi>,
pub(crate) invocation: Invocation,
/// The running call is a command or an event.
pub(crate) interactive: bool,
pub(crate) net_interactive: bool,
/// Editor window for pick, pick-many, input and confirm during action resolution.
pub(crate) action_prompt_client: Option<String>,
/// Tasks begun by the running call and not ended yet.
@@ -913,6 +930,9 @@ pub struct RuntimeSetup {
pub version: String,
/// Capabilities granted to the plugin.
pub granted: Vec<Capability>,
/// Whose grant this is (installed id, or the package directory of a
/// linked or project plugin): what `net:ask` answers are remembered for.
pub subject: ide_extension::ledger::Subject,
pub host: Arc<dyn HostApi>,
pub limits: Limits,
}
@@ -1022,9 +1042,11 @@ impl Runtime {
stderr_seen: 0,
plugin_id: setup.plugin_id,
granted: setup.granted,
subject: setup.subject,
host: setup.host,
invocation: Invocation::default(),
interactive: false,
net_interactive: false,
action_prompt_client: None,
open_tasks: Vec::new(),
last_report: None,
@@ -1637,6 +1659,7 @@ impl Runtime {
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
store.data_mut().interactive = true;
store.data_mut().net_interactive = true;
let r = e.plugin.openidea_plugin_commands().call_run(
&mut *store,
id,
@@ -1762,10 +1785,12 @@ impl Runtime {
if self.exports.views.is_none() {
return Err(CallError::missing_export("view-provider"));
}
let net_interactive = !matches!(&event, ViewEvent::Message { .. });
let event = event_to_wit_view(event);
let result = self.call(invocation, self.limits.timeout, |e, store| {
let ctx = store.data_mut().new_invocation()?;
store.data_mut().interactive = true;
store.data_mut().net_interactive = net_interactive;
let views = e.views.as_ref().expect("checked");
let r = views.call_handle(&mut *store, view, &event, wasmtime::component::Resource::new_borrow(ctx.rep()));
store.data_mut().drop_invocation(ctx);
@@ -1817,6 +1842,7 @@ impl Runtime {
let data = self.store.data_mut();
data.invocation = Invocation::default();
data.interactive = false;
data.net_interactive = false;
data.action_prompt_client = None;
// Tasks don't outlive the call that began them.
for id in std::mem::take(&mut data.open_tasks) {
+125 -8
View File
@@ -117,6 +117,45 @@ pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
Ok(real)
}
/// Resolves a content write's destination, including existing and dangling
/// symlinks in the final component. Entry operations use [`resolve_new_in_root`].
pub fn resolve_writable_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
let (root, joined) = joined_path(root, path)?;
let real = resolve_write_target(&joined, 40).map_err(|e| format!("{path}: {e}"))?;
if !real.starts_with(&root) {
return Err(format!("{path}: access outside the project root is not allowed"));
}
Ok(real)
}
fn resolve_write_target(path: &Path, links_left: usize) -> Result<PathBuf, std::io::Error> {
match dunce::canonicalize(path) {
Ok(real) => return Ok(real),
Err(error) => match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_symlink() => {
if links_left == 0 {
return Err(std::io::Error::other("too many symbolic links"));
}
let target = std::fs::read_link(path)?;
let target = if target.is_absolute() { target } else { path.parent().unwrap().join(target) };
return resolve_write_target(&target, links_left - 1);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
_ => return Err(error),
},
}
let parent = path.parent().ok_or_else(|| std::io::Error::other("no directory to put it in"))?;
let mut real = resolve_write_target(parent, links_left)?;
match path.components().next_back() {
Some(std::path::Component::ParentDir) => {
real.pop();
}
Some(std::path::Component::Normal(name)) => real.push(name),
_ => {}
}
if real != path { resolve_write_target(&real, links_left) } else { Ok(real) }
}
/// Reads a UTF-8 file that `resolve_in_root` already approved.
pub fn read_text(real: &Path) -> Result<String, String> {
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
@@ -194,9 +233,9 @@ pub fn resolve_granted(granted: &[Capability], path: &str) -> Result<PathBuf, Re
/// The real path `path` would be written to, when an `fs.write:<path>` grant
/// covers it.
///
/// A file that does not exist yet cannot be canonicalized, so its directory is
/// canonicalized instead and the name appended; an existing file (or link) is
/// followed first, so a link cannot be used to write outside the grant.
/// Missing paths and dangling links are resolved to their target location;
/// an existing file (or link) is followed first. The destination's directory
/// must exist.
pub fn resolve_writable(granted: &[Capability], path: &str) -> Result<PathBuf, Refusal> {
resolve(granted, path, Wanted::Write)
}
@@ -241,12 +280,14 @@ fn resolve(granted: &[Capability], path: &str, wanted: Wanted) -> Result<PathBuf
if covered { Ok(real) } else { Err(Refusal::Denied(cap.to_string())) }
}
/// Where `asked` would be created: its directory, resolved, plus its name.
/// Where `asked` would be created, including a dangling link's target.
fn resolve_unsaved(asked: &Path) -> Result<PathBuf, String> {
let name = asked.file_name().ok_or_else(|| "not a file name".to_string())?;
let parent = asked.parent().ok_or_else(|| "no directory to put it in".to_string())?;
let dir = dunce::canonicalize(parent).map_err(|_| "its directory does not exist".to_string())?;
Ok(dir.join(name))
let real = resolve_write_target(asked, 40).map_err(|e| e.to_string())?;
let parent = real.parent().ok_or_else(|| "no directory to put it in".to_string())?;
if !parent.is_dir() {
return Err("its directory does not exist".into());
}
Ok(real)
}
/// Simplifies Windows drive paths only when the plain form preserves their meaning.
@@ -387,6 +428,82 @@ mod tests {
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
}
#[cfg(unix)]
#[test]
fn writable_paths_follow_existing_and_dangling_symlinks() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::fs::write(root.join("inside.txt"), "before").unwrap();
for (name, target) in [
("outside", outside.clone()),
("dangling-outside", tmp.path().join("missing.txt")),
("inside", PathBuf::from("inside.txt")),
("dangling-inside", PathBuf::from("new/deep.txt")),
("chain", PathBuf::from("dangling-outside")),
("cycle", PathBuf::from("cycle")),
] {
symlink(target, root.join(name)).unwrap();
}
for name in ["outside", "dangling-outside", "chain"] {
assert!(resolve_writable_in_root(&root, name).unwrap_err().contains("outside the project root"));
assert!(
resolve_writable_in_root(&root, &format!("missing/../{name}"))
.unwrap_err()
.contains("outside the project root")
);
}
assert!(resolve_writable_in_root(&root, "cycle").is_err());
let inside = resolve_writable_in_root(&root, "inside").unwrap();
assert_eq!(inside, root.join("inside.txt"));
std::fs::write(inside, "after").unwrap();
assert_eq!(std::fs::read_to_string(root.join("inside.txt")).unwrap(), "after");
assert_eq!(resolve_writable_in_root(&root, "dangling-inside").unwrap(), root.join("new/deep.txt"));
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
assert!(!tmp.path().join("missing.txt").exists());
}
#[cfg(unix)]
#[test]
fn writable_grants_confine_dangling_symlink_targets() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("granted");
std::fs::create_dir(&root).unwrap();
let root = dunce::canonicalize(root).unwrap();
let grant = Capability::FsWrite(strip_verbatim(&root));
symlink("../missing.txt", root.join("outside")).unwrap();
symlink("new.txt", root.join("inside")).unwrap();
assert!(matches!(
resolve_writable(std::slice::from_ref(&grant), root.join("outside").to_str().unwrap()),
Err(Refusal::Denied(_))
));
assert_eq!(resolve_writable(&[grant], root.join("inside").to_str().unwrap()).unwrap(), root.join("new.txt"));
}
#[cfg(unix)]
#[test]
fn entry_operations_do_not_follow_outside_symlinks() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("proj");
std::fs::create_dir(&root).unwrap();
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
std::os::unix::fs::symlink(&outside, root.join("link")).unwrap();
let from = resolve_new_in_root(&root, "link").unwrap();
let to = resolve_new_in_root(&root, "renamed").unwrap();
std::fs::rename(&from, &to).unwrap();
assert!(!from.try_exists().unwrap());
assert!(std::fs::symlink_metadata(&to).unwrap().is_symlink());
std::fs::remove_file(resolve_new_in_root(&root, "renamed").unwrap()).unwrap();
assert!(std::fs::symlink_metadata(&to).is_err());
assert_eq!(std::fs::read_to_string(outside).unwrap(), "unchanged");
}
#[cfg(unix)]
#[test]
fn symlinks_cannot_escape() {
+24 -1
View File
@@ -94,6 +94,10 @@ pub enum Prompt {
Confirm { title: String, message: String },
#[serde(rename_all = "camelCase")]
PickDirectory { title: String, initial: Option<String> },
/// The host's own question under `net:ask` (never plugin-initiated): the
/// plugin reached an HTTPS host its manifest does not list.
#[serde(rename_all = "camelCase")]
NetPermission { host: String, session_only: bool },
}
/// Kinds of workbench events a plugin can subscribe to (`on-event:<kind>`).
@@ -437,7 +441,13 @@ pub fn net_host(url: &str) -> Result<String, String> {
return Err(format!("unsupported scheme {:?}", parsed.scheme()));
}
match parsed.host() {
Some(url::Host::Domain(host)) => Ok(host.trim_end_matches('.').to_ascii_lowercase()),
Some(url::Host::Domain(host)) => {
let host = host.trim_end_matches('.').to_ascii_lowercase();
if host.is_empty() || host.contains('*') {
return Err("URL must name one exact host".into());
}
Ok(host)
}
Some(url::Host::Ipv4(host)) => Ok(host.to_string()),
Some(url::Host::Ipv6(host)) => Ok(host.to_string()),
None => Err("URL has no host".into()),
@@ -479,8 +489,21 @@ mod tests {
net_capability("http://example.com/").unwrap(),
ide_extension::Capability::NetHttp("example.com".into())
);
for (url, host) in [
("https://EXAMPLE.com.:8443/", "example.com"),
("https://bücher.example/", "xn--bcher-kva.example"),
("https://xn--bcher-kva.example/", "xn--bcher-kva.example"),
("https://127.1/", "127.0.0.1"),
("https://[0:0:0:0:0:0:0:1]:8443/", "::1"),
("https://sub.example.com/", "sub.example.com"),
] {
assert_eq!(net_host(url).unwrap(), host);
}
assert_ne!(net_host("https://аpi.example/").unwrap(), net_host("https://api.example/").unwrap());
assert!(net_host("file:///etc/passwd").is_err());
assert!(net_host("example.com").is_err());
assert!(net_host("https://*.example.com/").is_err());
assert!(net_host("https://./").is_err());
}
}
+39 -4
View File
@@ -322,6 +322,23 @@ impl StoreData {
answer.map_err(to_wit)
}
/// The user's answer for a request to `host` under `net:ask`:
/// `Some(_)` allows it (`true`: the host is remembered). `None` when
/// nobody could be asked or the user refused. Commands and view actions pass
/// their window; background and scheduled calls ask nobody, so only an
/// already-allowed host connects there.
fn ask_net(&mut self, host: &str) -> Result<Option<bool>, wit::Error> {
let client = self.net_interactive.then(|| self.invocation.client.clone()).flatten();
let subject = self.subject.clone();
let started = Instant::now();
let answer = self.host.net_ask(&subject, client.as_deref(), host, &self.cancel.token());
// The wait doesn't count against the call's wall-clock budget.
let waited = started.elapsed();
self.deadline += waited;
self.call_started += waited;
answer.map_err(to_wit)
}
fn pick_prompt(
&mut self,
title: String,
@@ -761,7 +778,7 @@ impl workspace::HostProject for StoreData {
return Ok(Err(too_much_to_write(&path)));
}
let root = self.table.get(&this)?.root.clone();
let real = match sandbox::resolve_new_in_root(&root, &path) {
let real = match sandbox::resolve_writable_in_root(&root, &path) {
Ok(real) => real,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
@@ -1269,8 +1286,26 @@ impl net::Host for StoreData {
Ok(h) => h,
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
};
if let Err(e) = self.require(capability) {
return Ok(Err(e));
let mut grants = self.granted.clone();
if let Err(e) = self.require(capability.clone()) {
// `net:ask`: an HTTPS (or loopback) host the manifest does not list
// may ask the user — commands and view actions from a window only. Plain
// HTTP elsewhere always needs `net.http:<host>`.
let askable =
matches!(&capability, Capability::Net(_)) && self.granted.iter().any(|g| g.covers(&Capability::NetAsk));
let allowed = match &capability {
Capability::Net(host) if askable => match self.ask_net(host) {
Ok(Some(_)) => true,
Ok(None) => false,
// A cancelled or failed question is not a refusal.
Err(e) => return Ok(Err(e)),
},
_ => false,
};
if !allowed {
return Ok(Err(e));
}
grants.push(capability.clone());
}
if req.headers.iter().any(|h| h.value.contains("{{secret:"))
&& let Err(e) = self.require(Capability::Secrets)
@@ -1287,7 +1322,7 @@ impl net::Host for StoreData {
headers: req.headers.into_iter().map(|h| (h.name, h.value)).collect(),
body: req.body,
timeout: wanted.min(self.remaining()),
grants: self.granted.clone(),
grants,
};
let token = self.cancel.token();
Ok(self
Binary file not shown.
@@ -1 +1 @@
023542d758cd359aa4d1282b95ddd2edca9fcf746862700705dd637b3e524571
daecb28adad6f82f932ef70e042f520959d3429e9e2a23cda7df7ec05ba5f259
+2 -2
View File
@@ -1,13 +1,13 @@
id = "api14"
name = "API 1.4 host tests"
version = "0.1.0"
engines.openidea = "^1.4"
engines.openidea = "^1.5"
wasm = "dist/plugin.wasm"
activation = ["on-command:*", "on-event:file-closed", "on-event:settings-changed", "on-event:input-mode-changed"]
[capabilities]
required = ["storage"]
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "ipc:$TMPDIR/api14-*"]
optional = ["ide.commands", "project.read", "vcs.read", "events", "events.input", "editor.read", "secrets", "net:localhost", "net.http:example.com", "net:ask", "ipc:$TMPDIR/api14-*"]
[[contributes.secrets]]
name = "token"
+1 -1
View File
@@ -98,7 +98,7 @@ fn jar_applications_from_every_configuration_location() {
"-jar",
"../dist/server app.jar",
"--config",
&format!("{root}/server config.yml"),
&format!("{}/server config.yml", dir.path().to_string_lossy()),
""
])
);
+188 -1
View File
@@ -131,6 +131,117 @@ fn delve_build_flags(args: &[String], files: bool) -> Value {
)
}
/// An explicitly selected Go executable must also be the compiler Delve finds on PATH.
pub fn prepare_debug_compiler(
env: &mut std::collections::BTreeMap<String, String>,
extra_json: &Option<String>,
) -> ide_core::IdeResult<()> {
#[cfg(windows)]
for key in ["PATH", "GOROOT"] {
if let Some(previous) = env.keys().find(|name| name.eq_ignore_ascii_case(key) && name.as_str() != key).cloned()
{
let value = env.remove(&previous).unwrap();
env.entry(key.into()).or_insert(value);
}
}
let extra: Value = extra_json.as_deref().and_then(|value| serde_json::from_str(value).ok()).unwrap_or_default();
let Some(executable) = extra["__goExecutable"].as_str().filter(|value| *value != "go" && *value != "go.exe") else {
return Ok(());
};
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
let executable = ide_core::process::find_executable(executable, &path)
.ok_or_else(|| ide_core::IdeError::unavailable(format!("Go executable not found: {executable}")))?;
if !executable.file_name().is_some_and(|name| {
if cfg!(windows) {
let name = name.to_string_lossy();
name.eq_ignore_ascii_case("go") || name.eq_ignore_ascii_case("go.exe")
} else {
name == "go"
}
}) {
return Err(ide_core::IdeError::invalid("Delve requires a Go SDK executable named go or go.exe"));
}
if !env.contains_key("GOROOT") {
let mut command = std::process::Command::new(&executable);
command.args(["env", "GOROOT"]).envs(env.iter());
let goroot = crate::exec::query_command(command, std::time::Duration::from_secs(10))
.ok_or_else(|| ide_core::IdeError::unavailable("Cannot query the selected Go SDK"))?;
env.insert("GOROOT".into(), goroot);
}
let mut paths = vec![executable.parent().unwrap().to_path_buf()];
paths.extend(std::env::split_paths(&path));
let path = std::env::join_paths(paths).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
env.insert("PATH".into(), path.to_string_lossy().into_owned());
Ok(())
}
fn resolve_debug_target(
program: &mut Option<String>,
env: &std::collections::BTreeMap<String, String>,
extra: &mut Value,
flags: &mut Vec<String>,
) -> ide_core::IdeResult<()> {
let Some(targets) = extra.get("__goTargets") else { return Ok(()) };
let targets: Vec<String> =
serde_json::from_value(targets.clone()).map_err(|error| ide_core::IdeError::invalid(error.to_string()))?;
let dir = extra["__goBuildDir"]
.as_str()
.map(std::path::Path::new)
.ok_or_else(|| ide_core::IdeError::invalid("Go debug build directory is missing"))?;
if targets.iter().any(|target| target.ends_with(".go")) {
if !targets.iter().all(|target| target.ends_with(".go")) {
return Err(ide_core::IdeError::invalid("Select Go source files or one package for debugging"));
}
let files: Vec<_> =
targets.iter().map(|target| dunce::canonicalize(dir.join(target))).collect::<Result<_, _>>()?;
let first = files.first().ok_or_else(|| ide_core::IdeError::invalid("Go debug target is missing"))?;
if files.iter().any(|file| file.parent() != first.parent()) {
return Err(ide_core::IdeError::invalid("Go source files must be in the same directory"));
}
*program = Some(first.to_string_lossy().into_owned());
flags.extend(files.iter().skip(1).map(|file| file.to_string_lossy().into_owned()));
extra["__goSourceArgs"] = json!(true);
return Ok(());
}
let path = env.get("PATH").map(std::ffi::OsString::from).unwrap_or_else(crate::exec::search_path);
let go = ide_core::process::find_executable("go", &path)
.ok_or_else(|| ide_core::IdeError::unavailable("Go executable not found"))?;
let mut command = std::process::Command::new(go);
command.args(["list", "-find", "-f", "{{.Dir}}"]).current_dir(dir).envs(env.iter());
let mut args = flags.iter();
while let Some(flag) = args.next() {
let name = flag.split('=').next().unwrap_or(flag);
if ["-tags", "-mod", "-modfile", "-overlay", "-compiler"].contains(&name) {
command.arg(flag);
if name == flag
&& let Some(value) = args.next()
{
command.arg(value);
}
} else if ["-race", "-msan", "-asan"].contains(&name) {
command.arg(flag);
}
}
command.args(&targets);
let output = crate::exec::query_command(command, std::time::Duration::from_secs(10)).ok_or_else(|| {
ide_core::IdeError::invalid(format!(
"Cannot resolve Go debug target: {}. Check its module path and build flags.",
targets.join(" ")
))
})?;
let mut dirs: Vec<_> = output.lines().filter(|line| !line.trim().is_empty()).collect();
dirs.sort();
dirs.dedup();
if dirs.len() != 1 {
return Err(ide_core::IdeError::invalid(format!(
"Go debugging requires one package; this target selects {}. Select a package or test instead.",
dirs.len()
)));
}
*program = Some(dirs[0].to_owned());
Ok(())
}
pub fn apply_to_debug(
env: &mut std::collections::BTreeMap<String, String>,
program: &mut Option<String>,
@@ -170,6 +281,7 @@ pub fn apply_to_debug(
.collect::<Vec<_>>();
merge_flags(&mut args, &flags);
}
resolve_debug_target(program, env, &mut extra, &mut args)?;
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
if extra["mode"] == "test" {
if let Some(file) = files.first().and_then(Value::as_str).map(std::path::Path::new) {
@@ -190,7 +302,10 @@ pub fn apply_to_debug(
}
// Delve appends its -o after array build flags; positional source files
// require the string form so all compiler flags precede the file list.
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some() && extra["mode"] != "test");
extra["buildFlags"] = delve_build_flags(
&args,
(extra.get("__goFiles").is_some() && extra["mode"] != "test") || extra["__goSourceArgs"] == true,
);
}
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
std::fs::create_dir_all(output)?;
@@ -199,6 +314,10 @@ pub fn apply_to_debug(
map.remove("__goUseTags");
map.remove("__goToolArgs");
map.remove("__goFiles");
map.remove("__goExecutable");
map.remove("__goTargets");
map.remove("__goBuildDir");
map.remove("__goSourceArgs");
}
*extra_json = Some(extra.to_string());
Ok(())
@@ -209,6 +328,74 @@ mod command_tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn debug_targets_resolve_imports_and_reject_multiple_packages() {
if crate::exec::which("go").is_none() {
return;
}
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
std::fs::write(root.join("go.mod"), "module example.com/debugtargets\ngo 1.22\n").unwrap();
for name in ["a", "b"] {
std::fs::create_dir(root.join(name)).unwrap();
std::fs::write(root.join(name).join("lib.go"), "package p\nfunc Value() {}\n").unwrap();
}
let mut env = BTreeMap::from([("GOWORK".into(), "off".into())]);
let launch = |target: &str| {
Some(json!({"mode":"test", "__goToolArgs":[], "__goTargets":[target], "__goBuildDir":root}).to_string())
};
let mut program = None;
let mut extra = launch("example.com/debugtargets/a");
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
assert_eq!(program.as_deref(), root.join("a").to_str());
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
assert!(value.get("__goTargets").is_none());
let error =
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut launch("./..."), &GoBuildTags::default())
.unwrap_err();
assert!(error.to_string().contains("requires one package"), "{error}");
std::fs::write(root.join("main.go"), "package main\nfunc main(){helper()}\n").unwrap();
std::fs::write(root.join("helper.go"), "package main\nfunc helper(){}\n").unwrap();
let mut extra = Some(
json!({"mode":"debug","__goBuildDir":root,"__goTargets":["main.go","helper.go"],"__goToolArgs":[]})
.to_string(),
);
apply_to_debug(&mut env, &mut program, &mut Vec::new(), &mut extra, &GoBuildTags::default()).unwrap();
assert_eq!(program.as_deref(), root.join("main.go").to_str());
let value: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
assert_eq!(
value["buildFlags"],
format!("'{}'", root.join("helper.go").to_string_lossy().replace('\\', "\\\\").replace('\'', "\\'"))
);
}
#[cfg(windows)]
#[test]
fn compiler_selection_preserves_case_insensitive_windows_environment_keys() {
let mut env =
BTreeMap::from([("Path".into(), "explicit path".into()), ("GoRoot".into(), "explicit root".into())]);
prepare_debug_compiler(&mut env, &None).unwrap();
assert_eq!(env["PATH"], "explicit path");
assert_eq!(env["GOROOT"], "explicit root");
assert!(!env.contains_key("Path"));
assert!(!env.contains_key("GoRoot"));
}
#[test]
fn the_selected_go_compiler_precedes_the_existing_path() {
let Some(go) = crate::exec::which("go") else {
return;
};
let mut env = BTreeMap::from([("PATH".into(), "/unrelated".into())]);
let extra = Some(json!({"__goExecutable":go}).to_string());
prepare_debug_compiler(&mut env, &extra).unwrap();
assert_eq!(std::env::split_paths(&env["PATH"]).next().as_deref(), go.parent());
assert!(!env["GOROOT"].is_empty());
env.insert("GOROOT".into(), "explicit".into());
prepare_debug_compiler(&mut env, &extra).unwrap();
assert_eq!(env["GOROOT"], "explicit");
}
#[test]
fn detected_go_runs_inherit_tags_only_for_commands_that_accept_build_flags() {
let tags = GoBuildTags { custom: "integration".into(), ..Default::default() };
+1 -1
View File
@@ -1,5 +1,5 @@
//! `openidea-server` builds for remote development: the release of this
//! version publishes one per supported host platform (see `release.yml`).
//! version publishes one per supported host platform (see `release-build.yml`).
use crate::process::Http;
use crate::{InstallEvent, fetch_verified, sources};
+128
View File
@@ -0,0 +1,128 @@
import { expect, test } from "./fixtures";
for (const theme of ["light", "dark"] as const) {
for (const width of [140, 320]) {
test(`test runner keeps status columns aligned in ${theme} at ${width}px`, async ({ workbench: page }) => {
const id = await page.evaluate(async (theme) => {
const storePath = "/src/features/run/store.ts";
const resultsPath = "/src/features/run/testing/index.ts";
const themePath = "/src/workbench/theme.ts";
const run = await import(storePath);
const { TestResults } = await import(resultsPath);
const { previewTheme } = await import(themePath);
previewTheme(theme);
localStorage.setItem("openidea.run.testFilters", JSON.stringify({ showPassed: true, showIgnored: true }));
await run.startSampleOutput(0, 1);
const id = run.useRun.getState().activeSession;
const results = new TestResults();
results.apply([
{ type: "result", path: ["AlignmentSuite", "passed case"], status: "passed", durationMs: 12 },
{ type: "result", path: ["AlignmentSuite", "failed case"], status: "failed", durationMs: 23 },
{ type: "result", path: ["AlignmentSuite", "ignored case"], status: "skipped" },
{ type: "start", path: ["AlignmentSuite", "running case"] },
]);
results.node(["AlignmentSuite", "pending case"]);
run.runtime(id).tests = results;
const state = run.useRun.getState();
run.useRun.setState({
sessions: state.sessions.map((s: { id: string; config: object }) =>
s.id === id ? { ...s, status: "running", config: { ...s.config, isTest: true } } : s,
),
versions: { ...state.versions, [id]: (state.versions[id] ?? 0) + 1 },
});
return id;
}, theme);
const run = page.getByRole("region", { name: "Run", exact: true });
const panel = run.getByTestId("tests");
const tree = panel.getByRole("tree", { name: "Test results" });
await expect(tree.getByText("pending case", { exact: true })).toBeVisible();
const bounds = (await panel.boundingBox())!;
const handle = (await run.getByRole("separator").boundingBox())!;
const y = handle.y + handle.height / 2;
await page.mouse.move(handle.x + handle.width / 2, y);
await page.mouse.down();
await page.mouse.move(bounds.x + width, y, { steps: 4 });
await page.mouse.up();
await expect.poll(async () => Math.abs((await panel.boundingBox())!.width - width)).toBeLessThan(2);
if (process.env.OPENIDEA_TEST_RUNNER_SHOTS)
await panel.screenshot({ path: `${process.env.OPENIDEA_TEST_RUNNER_SHOTS}/${theme}-${width}.png` });
const cases = ["passed case", "failed case", "ignored case", "running case", "pending case"];
const row = (name: string) => tree.getByRole("treeitem").filter({ hasText: name });
const checkColumns = async (size: number) => {
const starts: number[] = [];
for (const name of cases) {
const icon = row(name).locator('svg[role="img"], svg[role="status"]');
const iconBounds = (await icon.boundingBox())!;
const dimensions = await icon.evaluate((node) => {
const style = getComputedStyle(node);
return { width: Number.parseFloat(style.width), height: Number.parseFloat(style.height) };
});
expect.soft(dimensions.width).toBe(size);
expect.soft(dimensions.height).toBe(size);
const label = (await row(name).getByText(name, { exact: true }).boundingBox())!;
const rowBounds = (await row(name).boundingBox())!;
starts.push(label.x);
expect
.soft(Math.abs(iconBounds.y + iconBounds.height / 2 - (rowBounds.y + rowBounds.height / 2)))
.toBeLessThan(1);
}
expect.soft(Math.max(...starts) - Math.min(...starts)).toBeLessThan(1);
};
await checkColumns(16);
const paneBounds = (await panel.boundingBox())!;
for (const title of ["Show Passed", "Show Ignored"]) {
const button = panel.getByRole("button", { name: title, exact: true });
const rect = (await button.boundingBox())!;
expect(rect.x).toBeGreaterThanOrEqual(paneBounds.x);
expect(rect.x + rect.width).toBeLessThanOrEqual(paneBounds.x + paneBounds.width + 1);
expect.soft((await button.locator("svg").boundingBox())!.width).toBe(16);
}
const fills = await row("failed case")
.getByRole("img", { name: "Failed", exact: true })
.locator("[fill]")
.evaluateAll((nodes) =>
nodes.filter((node) => node.getBoundingClientRect().width > 0).map((node) => getComputedStyle(node).fill),
);
expect.soft(fills).toContain(theme === "light" ? "rgb(255, 175, 15)" : "rgb(242, 197, 92)");
await page.evaluate(async () => {
const path = "/src/ui/iconAppearance.ts";
(await import(path)).setIconAppearance({ iconSize: 20, rowHeight: 28 });
});
await checkColumns(20);
const before = (await row("running case").getByText("running case", { exact: true }).boundingBox())!.x;
await page.evaluate(async (id) => {
const path = "/src/features/run/store.ts";
const run = await import(path);
run
.runtime(id)
.tests.apply([
{ type: "result", path: ["AlignmentSuite", "running case"], status: "passed", durationMs: 34 },
]);
const state = run.useRun.getState();
run.useRun.setState({ versions: { ...state.versions, [id]: (state.versions[id] ?? 0) + 1 } });
}, id);
await expect(row("running case").getByRole("img", { name: "Passed", exact: true })).toBeVisible();
expect.soft((await row("running case").getByText("running case", { exact: true }).boundingBox())!.x).toBe(before);
await page.evaluate(async () => {
const path = "/src/ui/iconAppearance.ts";
(await import(path)).setIconAppearance(null);
});
const showPassed = panel.getByRole("button", { name: "Show Passed", exact: true });
await showPassed.focus();
await page.keyboard.press("Enter");
await expect(row("passed case")).toHaveCount(0);
await expect(row("running case")).toHaveCount(0);
await showPassed.click();
await expect(row("passed case")).toBeVisible();
const showIgnored = panel.getByRole("button", { name: "Show Ignored", exact: true });
await showIgnored.click();
await expect(row("ignored case")).toHaveCount(0);
await showIgnored.click();
await expect(row("ignored case")).toBeVisible();
});
}
}
+16 -10
View File
@@ -13,6 +13,8 @@ something to show.
| `go-app/` | Go 1.22+ | `go run ./cmd/server` | `go test ./...` | `OPENIDEA_FAIL=1 go test ./...` | unused `fmt.Sprintf` result in `internal/store/store.go` |
| `kotlin-gradle/` | JDK 21, Gradle 8 (no wrapper) | `gradle run` | `gradle test` | `gradle test -PopenideaFail` | unused variable in `App.kt` |
| `java-app/` | JDK 21, Maven 3.9 | `mvn compile exec:java` | `mvn test` | `mvn test -DopenideaFail=true` | unused variable in `App.java` |
| `c-app/` | gcc / g++ 12+, make, GDB 14+ | `make all` | — | — | — |
| `php-app/` | PHP 8.5, Composer | `composer quote` | `vendor/bin/pest` | `OPENIDEA_FAIL=1 vendor/bin/pest` | unused variable in `ThresholdCustoms.php` |
| `polyglot/` | Go, TS, Python, C, shell, SQL, TOML/YAML/JSON, proto, Terraform | `make` | `make test` | — | unused variable in `native/hash.c` |
The fixtures are standalone: `rust-app` declares its own `[workspace]` and
@@ -36,7 +38,8 @@ git init -q && git add -A && git commit -qm "initial" # for the VCS checks
Then open the folder with **File › Open…** (`⌘⇧⌥O`) or from the Welcome screen.
For `ts-app` / `polyglot/web` run `pnpm install` first; for `python-app` create a
venv (`python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'`).
venv (`python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'`); for
`php-app` run `composer install` (debugging it needs Xdebug in the PHP on `PATH`).
Missing language servers / debuggers can be installed from the IDE:
**Settings › Languages & Frameworks › Language Servers**, or from the
@@ -57,17 +60,18 @@ language detection and highlighting.
### Project tree
- [ ] Arrow keys move the selection; `→`/`←` expand/collapse; `Enter`/`F4` opens a file.
- [ ] Ignored folders (`target/`, `node_modules/`, `build/`) appear dimmed.
- [ ] Ignored folders (`target/`, `node_modules/`, `build/`, `vendor/`) appear dimmed.
- [ ] New file (`⌘N`), rename (`⇧F6`), delete (`⌫`) work and the tree updates.
### Search Everywhere & navigation
- [ ] Double `⇧` opens Search Everywhere; typing `parser` finds `src/parser.rs`.
- [ ] `⌘O` (classes) finds `Inventory` / `Cart` / `TaskList` / `Library`.
- [ ] `⌘O` (classes) finds `Inventory` / `Cart` / `TaskList` / `Library` /
`Shipment`.
- [ ] `⌘⇧O` (files) and `⌘⌥O` (symbols) find `format_price` / `formatMoney`.
- [ ] `⌘E` lists recent files; `⌘[` / `⌘]` navigate back/forward.
- [ ] `⌘B` on a call (`format_price` in `main.rs`, `cart.total()` in `index.ts`)
jumps to the declaration; `⌥F7` lists usages.
- [ ] `⌘B` on a call (`format_price` in `main.rs`, `cart.total()` in `index.ts`,
`billableGrams` in `quote.php`) jumps to the declaration; `⌥F7` lists usages.
- [ ] `⌘F12` shows the file structure; `⌘L` goes to a line.
### Editing & code insight
@@ -75,7 +79,8 @@ language detection and highlighting.
- [ ] Typing in a file marks the tab modified; `⌘S` saves; external edits reload.
- [ ] `⌘F` opens the find bar (match case / words / regex); `⌘R` replaces.
- [ ] `⌘⇧F` finds `TODO` across the project and opens results.
- [ ] Completion (`⌃Space`) on `inventory.` / `cart.` / `tasks.` lists members.
- [ ] Completion (`⌃Space`) on `inventory.` / `cart.` / `tasks.` / `$parcel->` lists
members.
- [ ] Hover / `F1` shows documentation; `⌘P` shows parameter info.
- [ ] Warnings: the intentional warning is underlined and listed in Problems (`⌘6`);
`F2` jumps to it; `⌥Enter` offers a quick fix.
@@ -91,7 +96,8 @@ language detection and highlighting.
### Run configurations & tests
- [ ] Run configurations are detected: cargo run/test (Rust), package.json scripts
(TS), pytest (Python), `go test` / `go run` (Go), Gradle tasks (Kotlin).
(TS), pytest (Python), `go test` / `go run` (Go), Gradle tasks (Kotlin),
composer.json scripts and Pest (PHP).
- [ ] Gutter run icons appear next to `main` and test functions; running one opens
the Run tool window (`⌘4`) with the output.
- [ ] The test tree shows passed tests; enabling the failing test (table above)
@@ -103,10 +109,10 @@ language detection and highlighting.
- [ ] Each fixture's entry point has a "Set a breakpoint here" comment: toggle a
breakpoint there (`⌘F8`) and start debugging (`⌃D`).
- [ ] Execution stops on the line; Frames and Variables show `inventory` / `cart` /
`tasks` / `library`; inline values appear in the editor.
`tasks` / `library` / `$shipment`; inline values appear in the editor.
- [ ] Step over (`F8`), into (`F7`), out (`⇧F8`), resume (`⌘⌥R`) and evaluate (`⌥F8`).
- [ ] If the debug adapter is missing (CodeLLDB, debugpy, Delve), the error offers
the installer and debugging works after installing.
- [ ] If the debug adapter is missing (CodeLLDB, debugpy, Delve, PHP Debug), the error
offers the installer and debugging works after installing.
### Git
+19
View File
@@ -0,0 +1,19 @@
# Debugging the entry points with GDB, picked in the configuration's Debug adapter
# control (explicit choice: the C/C++ default stays lldb-dap). GDB 14+ has the
# DAP interpreter; `make all` builds `bin/` first.
[[configurations]]
id = "user:app-c"
name = "app-c (GDB)"
kind = "shell"
command = "bin/app-c"
debug-adapter = "gdb"
env = { OPENIDEA_GDB_FIXTURE = "present" }
[[configurations]]
id = "user:app-cpp"
name = "app-cpp (GDB)"
kind = "shell"
command = "bin/app-cpp"
debug-adapter = "gdb"
env = { OPENIDEA_GDB_FIXTURE = "present" }
+18
View File
@@ -0,0 +1,18 @@
CFLAGS = -g -O0 -std=c11
CXXFLAGS = -g -O0 -std=c++17
.PHONY: all app-c app-cpp clean
all: app-c app-cpp
app-c: bin
$(CC) $(CFLAGS) -o bin/app-c src/main.c
app-cpp: bin
$(CXX) $(CXXFLAGS) -o bin/app-cpp src/main.cpp
bin:
mkdir -p bin
clean:
rm -rf bin
+33
View File
@@ -0,0 +1,33 @@
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* FNV-1a 64-bit, one byte at a time: enough of a call stack to inspect. */
static uint64_t mix(uint64_t h, unsigned char byte) {
h ^= byte;
h *= 1099511628211ULL;
return h;
}
static uint64_t hash(uint64_t h, const char *s) {
for (; *s; s++) {
h = mix(h, (unsigned char)*s);
}
return h;
}
int main(void) {
const char *fixture = getenv("OPENIDEA_GDB_FIXTURE");
int environment_ok = getenv("PATH") != NULL && fixture != NULL && strcmp(fixture, "present") == 0;
const char *words[] = {"openidea", "gdb", "dap"};
uint64_t hashes[3];
for (int i = 0; i < 3; i++) {
hashes[i] = hash(1469598103934665603ULL, words[i]);
}
uint64_t value = hashes[0];
// Set a breakpoint here (QA: debugger) and inspect `value`.
value = hash(1469598103934665603ULL, words[2]);
printf("%s %016llx\n", words[2], (unsigned long long)value);
return environment_ok ? 0 : 1;
}
+23
View File
@@ -0,0 +1,23 @@
#include <iostream>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
static std::string banner(const std::string &project, int n) {
return project + " #" + std::to_string(n);
}
int main() {
const char *fixture = std::getenv("OPENIDEA_GDB_FIXTURE");
int environment_ok = std::getenv("PATH") != nullptr && fixture != nullptr && std::strcmp(fixture, "present") == 0;
std::vector<std::string> log;
for (int i = 1; i <= 3; i++) {
log.push_back(banner("openidea", i));
}
std::string line;
// Set a breakpoint here (QA: debugger) and inspect `line`.
line = banner("openidea", 3);
std::cout << line << "\n";
return environment_ok ? 0 : 1;
}
+4
View File
@@ -0,0 +1,4 @@
/vendor/
/composer.lock
/.phpunit.cache/
/.phpunit.result.cache
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Shipping\Dimensions;
use App\Shipping\Parcel;
use App\Shipping\Route;
use App\Shipping\Shipment;
use App\Shipping\Tariff;
use App\Shipping\ThresholdCustoms;
use Uri\Rfc3986\Uri;
require __DIR__.'/../vendor/autoload.php';
function money(int $cents): string
{
return $cents / 100
|> (fn (float $dollars): string => number_format($dollars, 2))
|> (fn (string $amount): string => '$'.$amount);
}
$tariff = new Tariff(['air' => 1000, 'ground' => 500]);
$customs = new ThresholdCustoms;
$tracker = new Uri('https://track.example.com');
$shipment = new Shipment('FL-0412', Route::Air)
->add(new Parcel('PCL-0001', 'Northwind Books', 1200, new Dimensions(30, 20, 10), 4500))
->add(new Parcel('PCL-0002', 'Bluebird Electronics', 2400, new Dimensions(40, 30, 20), 6000))
->add(new Parcel('PCL-0003', 'Bluebird Electronics', 800, new Dimensions(20, 15, 10), 5500));
foreach ($shipment->byStore() as $store => $parcels) {
$freightCents = 0;
foreach ($parcels as $parcel) {
$cents = $tariff->freightCents($parcel, $shipment->route);
$freightCents += $cents;
printf("%s %.2f kg billable freight %s\n", $parcel->code, $parcel->billableGrams($shipment->route) / 1000, money($cents));
}
$charges = $customs->assess($parcels, $freightCents);
$summary = $charges->isDutyFree() ? 'duty-free' : sprintf('VAT %s, clearance fee %s', money($charges->vatCents), money($charges->feeCents));
echo "$store: $summary\n";
}
$heaviest = $shipment->heaviest(); // Set a breakpoint here
if ($heaviest !== null) {
echo "Heaviest: $heaviest->code, tracking {$heaviest->trackingUrl($tracker)->toString()}\n";
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "openidea/php-app",
"description": "OpenIDEA QA fixture: shipping quotes and customs charges for forwarded parcels.",
"type": "project",
"license": "MIT",
"require": {
"php": "^8.5"
},
"require-dev": {
"laravel/pint": "^1.29",
"pestphp/pest": "^5.0",
"phpstan/phpstan": "^2.1"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"quote": "php bin/quote.php",
"lint": "pint --test",
"analyse": "phpstan analyse"
},
"config": {
"allow-plugins": {
"pestphp/pest-plugin": true
}
}
}
+5
View File
@@ -0,0 +1,5 @@
parameters:
level: 9
paths:
- src
- bin
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
+19
View File
@@ -0,0 +1,19 @@
{
"preset": "laravel",
"rules": {
"cast_spaces": {
"space": "single"
},
"phpdoc_line_span": {
"case": "single",
"class": "single",
"const": "single",
"function": "single",
"method": "single",
"other": "single",
"property": "single",
"trait_import": "single"
},
"not_operator_with_successor_space": false
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
final readonly class Charges
{
public function __construct(
public int $vatCents,
public int $feeCents,
) {}
public static function dutyFree(): self
{
return new self(0, 0);
}
public function isDutyFree(): bool
{
return $this->vatCents === 0 && $this->feeCents === 0;
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
interface CustomsPolicy
{
public string $name { get; }
/** @param list<Parcel> $parcels */
public function assess(array $parcels, int $freightCents): Charges;
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
final class Dimensions
{
public int $volumetricGrams {
get => intdiv($this->lengthCm * $this->widthCm * $this->heightCm, 5);
}
public function __construct(
public readonly int $lengthCm,
public readonly int $widthCm,
public readonly int $heightCm,
) {}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use DomainException;
final class InvalidStatusTransition extends DomainException
{
public static function between(Parcel $parcel, ParcelStatus $next): self
{
return new self(sprintf("%s can't move from %s to %s", $parcel->code, $parcel->status->value, $next->value));
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use NoDiscard;
use Uri\Rfc3986\Uri;
final readonly class Parcel
{
public function __construct(
public string $code,
public string $store,
public int $weightGrams,
public Dimensions $dimensions,
public int $valueCents,
public ParcelStatus $status = ParcelStatus::Expected,
) {}
public function billableGrams(Route $route): int
{
return max($this->weightGrams, $this->dimensions->volumetricGrams, $route->minimumGrams());
}
#[NoDiscard('a parcel is immutable, use the returned one')]
public function moveTo(ParcelStatus $next): self
{
if (!$this->status->canMoveTo($next)) {
throw InvalidStatusTransition::between($this, $next);
}
return clone ($this, ['status' => $next]);
}
public function trackingUrl(Uri $tracker): Uri
{
return $tracker->withPath("/parcels/$this->code");
}
}
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
enum ParcelStatus: string
{
case Expected = 'expected';
case Received = 'received';
case AwaitingContents = 'awaiting_contents';
case ContentsConfirmed = 'contents_confirmed';
case InTransit = 'in_transit';
case Arrived = 'arrived';
case Held = 'held';
case Ready = 'ready';
case Delivered = 'delivered';
public function canMoveTo(self $next): bool
{
return match ($this) {
self::Expected => $next === self::Received,
self::Received => $next === self::AwaitingContents,
self::AwaitingContents => $next === self::ContentsConfirmed,
self::ContentsConfirmed => $next === self::InTransit,
self::InTransit => $next === self::Arrived,
self::Arrived => in_array($next, [self::Ready, self::Held], true),
self::Held => $next === self::Ready,
self::Ready => $next === self::Delivered,
self::Delivered => false,
};
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use Deprecated;
enum Route: string
{
case Air = 'air';
case Ground = 'ground';
#[Deprecated('sea freight is no longer offered', since: '2026-01')]
case Sea = 'sea';
public function minimumGrams(): int
{
return match ($this) {
self::Air => 100,
default => 1000,
};
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use ArrayIterator;
use Countable;
use IteratorAggregate;
/** @implements IteratorAggregate<int, Parcel> */
final class Shipment implements Countable, IteratorAggregate
{
/** @var list<Parcel> */
public private(set) array $parcels = [];
public function __construct(
public readonly string $flight,
public readonly Route $route,
) {}
public function add(Parcel $parcel): self
{
$this->parcels[] = $parcel;
return $this;
}
public function heaviest(): ?Parcel
{
return $this->parcels
|> (fn (array $parcels): array => $this->sortedByBillableWeight($parcels))
|> array_last(...);
}
public function isReadyForDeparture(): bool
{
return array_all($this->parcels, fn (Parcel $parcel): bool => $parcel->status === ParcelStatus::ContentsConfirmed);
}
/** @return array<string, list<Parcel>> */
public function byStore(): array
{
$groups = [];
foreach ($this->parcels as $parcel) {
$groups[$parcel->store][] = $parcel;
}
return $groups;
}
/** @return ArrayIterator<int, Parcel> */
public function getIterator(): ArrayIterator
{
return new ArrayIterator($this->parcels);
}
public function count(): int
{
return count($this->parcels);
}
/**
* @param list<Parcel> $parcels
* @return list<Parcel>
*/
private function sortedByBillableWeight(array $parcels): array
{
usort($parcels, fn (Parcel $a, Parcel $b): int => $a->billableGrams($this->route) <=> $b->billableGrams($this->route));
return $parcels;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
final readonly class Tariff
{
/** @param array<string, int> $centsPerKg */
public function __construct(private array $centsPerKg) {}
public function freightCents(Parcel $parcel, Route $route): int
{
$grams = $parcel->billableGrams($route);
return intdiv($grams * $this->centsPerKg[$route->value] + 999, 1000);
}
}
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Shipping;
use Closure;
use Override;
final class ThresholdCustoms implements CustomsPolicy
{
private const int DutyFreeCents = 100_00;
private const int DutyFreeGrams = 20_000;
private const int VatPercent = 20;
private const Closure ValueCents = static function (Parcel $parcel): int {
return $parcel->valueCents;
};
private const Closure WeightGrams = static function (Parcel $parcel): int {
return $parcel->weightGrams;
};
#[Override]
public string $name = 'threshold';
#[Override]
public function assess(array $parcels, int $freightCents): Charges
{
$valueCents = $parcels |> (fn (array $all): array => array_map(self::ValueCents, $all)) |> array_sum(...);
$grams = $parcels |> (fn (array $all): array => array_map(self::WeightGrams, $all)) |> array_sum(...);
$parcelCount = count($parcels);
if (!$this->exceedsDutyFree($valueCents, $grams)) {
return Charges::dutyFree();
}
// TODO: add the duty by goods code
$vatCents = intdiv(($valueCents + $freightCents) * self::VatPercent, 100);
return new Charges($vatCents, $this->clearanceFee($valueCents));
}
private function exceedsDutyFree(int $valueCents, int $grams): bool
{
if ($valueCents > self::DutyFreeCents) {
return true;
}
return $grams > self::DutyFreeGrams;
}
private function clearanceFee(int $valueCents): int
{
// FIXME: read the tiers from configuration
return match (true) {
$valueCents <= 1_000_00 => 5_00,
$valueCents <= 5_000_00 => 25_00,
default => 75_00,
};
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use App\Shipping\Charges;
use App\Shipping\CustomsPolicy;
use App\Shipping\Parcel;
use App\Shipping\Tariff;
use App\Shipping\ThresholdCustoms;
arch('value objects are final and readonly')
->expect([Charges::class, Parcel::class, Tariff::class])
->toBeFinal()
->toBeReadonly();
arch('customs policies implement the contract')
->expect(ThresholdCustoms::class)
->toImplement(CustomsPolicy::class);
arch('application code does not contain debug helpers')
->expect('App')
->not->toUse(['dd', 'dump', 'var_dump']);
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Shipping\Dimensions;
use App\Shipping\InvalidStatusTransition;
use App\Shipping\ParcelStatus;
use App\Shipping\Route;
use Tests\TestSupport\ParcelFactory;
use Uri\Rfc3986\Uri;
it('bills the volumetric weight of a light bulky parcel', function (): void {
$parcel = ParcelFactory::make(weightGrams: 2400, dimensions: new Dimensions(40, 30, 20));
expect($parcel->billableGrams(Route::Air))->toBe(4800);
});
it('bills the route minimum for a small parcel', function (): void {
$parcel = ParcelFactory::make(weightGrams: 300, dimensions: new Dimensions(10, 10, 2));
expect($parcel->billableGrams(Route::Ground))->toBe(1000);
});
it('moves to the next status in a copy', function (): void {
$expected = ParcelFactory::make();
$received = $expected->moveTo(ParcelStatus::Received);
expect($received->status)->toBe(ParcelStatus::Received)
->and($expected->status)->toBe(ParcelStatus::Expected)
->and($received->code)->toBe($expected->code);
});
it('rejects a skipped status', function (): void {
(void) ParcelFactory::make()->moveTo(ParcelStatus::Received)->moveTo(ParcelStatus::InTransit);
})->throws(InvalidStatusTransition::class);
it('links to its tracking page', function (): void {
$url = ParcelFactory::make()->trackingUrl(new Uri('https://track.example.com'));
expect($url->toString())->toBe('https://track.example.com/parcels/PCL-0009');
});
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use App\Shipping\Dimensions;
use App\Shipping\ParcelStatus;
use App\Shipping\Route;
use App\Shipping\Shipment;
use Tests\TestSupport\ParcelFactory;
it('finds the heaviest parcel by billable weight', function (): void {
$bulky = ParcelFactory::make(weightGrams: 900, dimensions: new Dimensions(40, 30, 20));
$dense = ParcelFactory::make(weightGrams: 3000, dimensions: new Dimensions(10, 10, 10));
$shipment = new Shipment('FL-0412', Route::Air)->add($dense)->add($bulky);
expect($shipment->heaviest())->toBe($bulky);
});
it('departs once every parcel has confirmed contents', function (): void {
$confirmed = ParcelFactory::make()
->moveTo(ParcelStatus::Received)
->moveTo(ParcelStatus::AwaitingContents)
->moveTo(ParcelStatus::ContentsConfirmed);
$shipment = new Shipment('FL-0412', Route::Air)->add($confirmed);
expect($shipment->isReadyForDeparture())->toBeTrue()
->and($shipment->add(ParcelFactory::make())->isReadyForDeparture())->toBeFalse();
});
it('keeps its parcel list read-only from outside', function (): void {
$shipment = new Shipment('FL-0412', Route::Air);
expect(fn () => $shipment->parcels = [])->toThrow(Error::class);
});
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
use App\Shipping\ThresholdCustoms;
use Tests\TestSupport\ParcelFactory;
it('clears a small purchase duty-free', function (): void {
$charges = new ThresholdCustoms()->assess([ParcelFactory::make(valueCents: 4500)], 1200);
expect($charges->isDutyFree())->toBeTrue();
});
it('sums the parcels of one store', function (): void {
$charges = new ThresholdCustoms()->assess([ParcelFactory::make(valueCents: 6000), ParcelFactory::make(valueCents: 5500)], 2000);
expect($charges->vatCents)->toBe(2700)
->and($charges->feeCents)->toBe(500);
});
it('intentionally fails (set OPENIDEA_FAIL=1)', function (): void {
expect(1)->toBe(2);
})->skip(getenv('OPENIDEA_FAIL') !== '1', 'intentionally fails (set OPENIDEA_FAIL=1)');
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Tests\TestSupport;
use App\Shipping\Dimensions;
use App\Shipping\Parcel;
final class ParcelFactory
{
public static function make(int $weightGrams = 1000, ?Dimensions $dimensions = null, int $valueCents = 5000): Parcel
{
return new Parcel('PCL-0009', 'Bluebird Electronics', $weightGrams, $dimensions ?? new Dimensions(20, 20, 10), $valueCents);
}
}
+2 -2
View File
@@ -17,8 +17,8 @@ const config: KnipConfig = {
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
ignore: ["plugins/**/web-src/**"],
ignoreExportsUsedInFile: true,
// lint-staged formats Rust files with the toolchain's rustfmt.
ignoreBinaries: ["rustfmt"],
// System tools used by formatting and language smoke fixtures.
ignoreBinaries: ["rustfmt", "composer", "make"],
compilers: {
// Fonts come in through `@import` in global.css.
css: (text: string) => [...text.matchAll(/(?<=@)import[^;]+/g)].join("\n"),
+2 -1
View File
@@ -30,7 +30,8 @@
"e2e": "playwright test",
"perf": "node perf/run.ts",
"perf:rust": "node perf/rust.ts",
"release:prepare": "node scripts/release-prepare.ts"
"release:prepare": "node scripts/release-prepare.ts",
"nightly:prepare": "node scripts/nightly-prepare.ts"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
+27
View File
@@ -306,6 +306,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins.
| `events.input` | medium | `action-performed` and `input-mode-changed` events |
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
| `net:<host>` | medium | `host::fetch` over HTTPS (also loopback HTTP); `*.example.com` matches subdomains |
| `net:ask` | medium | Ask the user at the first request to an HTTPS (or loopback) host that is not declared; one answer per host, revocable; API 1.5 |
| `net.http:<host>` | medium | Unencrypted HTTP to that host; API 1.4, same wildcard rules as `net:` |
| `ipc:<path>` | high | Local Unix sockets or Windows named pipes on the project machine; API 1.4 |
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set`, `secret_set`, `secret_delete` |
@@ -373,6 +374,9 @@ working. New in 1.5:
- [Notifications for a project](#notifications-with-commands):
`Project::notify` shows a notification in a window of that project only,
also from scheduled or background work.
- [Permissions](#capabilities): `net:ask` lets a plugin connect to HTTPS hosts
one at a time, asking at first use instead of listing every host
(see _[Network and secrets](#network-and-secrets)_).
### API 1.4
@@ -1211,6 +1215,29 @@ connecting to the destination; HTTPS → HTTP also requires `net.http:<host>`,
even on loopback. Responses are limited to 8 MiB. Requests run on the project
machine. A manifest using `net.http:` or `ipc:` declares `engines.openidea = "^1.4"`.
A plugin with `net:ask` (API 1.5) may reach an HTTPS (or loopback) host that is
not in the manifest: the first request asks the user, naming the plugin and the
full host, with **Allow once**, **Always allow** and **Deny**. "Always" is
remembered per exact, normalized host in the grant ledger and never asks again; the allowed
hosts are listed in the plugin's permissions (Settings › Plugins), where each
can be removed to ask again. Remote sessions offer **Allow for this session**
and **Deny** instead: approval is kept only in memory until the remote connection ends.
Unattended calls to a host that is not yet allowed return `denied` without a dialog,
so the plugin should tell the user to run the action once interactively. Plain HTTP
to other hosts never goes through this flow: declare `net.http:<host>`. A
manifest using `net:ask` declares `engines.openidea = "^1.5"`.
A host answer covers all ports of that host, like `net:<host>`, and never
covers subdomains or wildcard patterns. IDNs are shown in ASCII (punycode);
IP literals use their canonical form. Redirects are checked against the
request's grants and cannot open another permission dialog. Deny is the
default button; dismissal refuses the request. Only one network question per
plugin may be pending; a refusal or dismissal suppresses further questions
for 30 seconds, even for other hosts. Hidden windows refuse new questions and
close pending ones. Automatic event handlers and providers do not ask.
Removing `net:ask` during an update or revoking it clears remembered hosts;
uninstalling removes the plugin's ledger entry.
`ipc:<path>` grants a local byte stream: an absolute Unix socket path (the host
expands `~/`, `$TMPDIR/` and `$XDG_RUNTIME_DIR/`) or `\\.\pipe\<name>` on Windows.
Only a grant's last path segment may contain `*`, for example
+34
View File
@@ -0,0 +1,34 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { NIGHTLY_ENDPOINT, nightlyVersion, setNightlyIdentity } from "./nightly-prepare.ts";
describe("nightlyVersion", () => {
it("is one patch above the current version, numbered by its stamp", () => {
expect(nightlyVersion("0.1.39", "202611081417")).toBe("0.1.40-nightly.202611081417");
});
it.each([undefined, "", "2026", "012611081417", "20261108141a"])("rejects the stamp %s", (stamp) => {
expect(() => nightlyVersion("0.1.39", stamp)).toThrow("stamp");
});
it("rejects a current version that is not a release version", () => {
expect(() => nightlyVersion("0.1.40-nightly.202611081417", "202611081417")).toThrow("expected a version");
});
});
describe("setNightlyIdentity", () => {
const conf = JSON.parse(setNightlyIdentity(readFileSync("src-tauri/tauri.conf.json", "utf8")));
it("is an app of its own that updates from the nightly manifest", () => {
expect(conf.productName).toBe("OpenIDEA Nightly");
expect(conf.identifier).toBe("ai.l0ng.openidea.nightly");
expect(conf.plugins.updater.endpoints).toEqual([NIGHTLY_ENDPOINT]);
});
it("leaves out the bundles that reject prerelease versions", () => {
expect(conf.bundle.targets).not.toContain("msi");
expect(conf.bundle.targets).not.toContain("rpm");
expect(conf.bundle.createUpdaterArtifacts).toBe(true);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* Prepares a nightly build's commit: `node scripts/nightly-prepare.ts 202611081417` sets the
* version one patch above the current one, with that stamp as its prerelease number, in the
* files `release:prepare` covers, and gives tauri.conf.json the nightly app's identity, so it
* installs next to the stable app and updates from its own manifest. Prints the version.
*/
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { cargoVersion, edit, parseVersion, setCargoVersion, setJsonVersion } from "./release-prepare.ts";
export const NIGHTLY_ENDPOINT = "https://github.com/l0ng-ai/openidea/releases/download/nightly/latest.json";
/** `0.1.40-nightly.202611081417` from `0.1.39`: always above the last stable version and the last nightly. */
export function nightlyVersion(current: string, stamp: string | undefined): string {
if (!stamp || !/^[1-9]\d{11}$/.test(stamp)) throw new Error(`expected a YYYYMMDDHHMM stamp, got ${stamp}`);
const [major, minor, patch] = parseVersion(current).split(".").map(Number);
return `${major}.${minor}.${patch! + 1}-nightly.${stamp}`;
}
/** MSI and RPM reject prerelease versions, so a nightly build leaves them out of its bundles. */
export function setNightlyIdentity(json: string): string {
const conf = JSON.parse(json);
conf.productName = "OpenIDEA Nightly";
conf.identifier = "ai.l0ng.openidea.nightly";
conf.bundle.targets = ["app", "dmg", "appimage", "deb", "nsis"];
conf.plugins.updater.endpoints = [NIGHTLY_ENDPOINT];
return `${JSON.stringify(conf, null, 2)}\n`;
}
function main() {
const root = fileURLToPath(new URL("..", import.meta.url));
const version = nightlyVersion(cargoVersion(readFileSync(join(root, "Cargo.toml"), "utf8")), process.argv[2]);
edit(join(root, "Cargo.toml"), (text) => setCargoVersion(text, version));
edit(join(root, "package.json"), (text) => setJsonVersion(text, version));
edit(join(root, "src-tauri/tauri.conf.json"), (text) => setNightlyIdentity(setJsonVersion(text, version)));
const cargo = spawnSync("cargo", ["update", "--workspace"], {
cwd: root,
stdio: ["ignore", 2, 2],
});
if (cargo.status !== 0) throw new Error("cargo update --workspace failed");
console.log(version);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
try {
main();
} catch (error) {
console.error(`nightly-prepare: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function syncApiSnapshot(root: string): { from: string; to: string; copie
return { from, to, copied: true };
}
function edit(path: string, change: (text: string) => string) {
export function edit(path: string, change: (text: string) => string) {
const before = readFileSync(path, "utf8");
const after = change(before);
if (after === before) throw new Error(`${path}: nothing changed`);
+58 -4
View File
@@ -1,7 +1,9 @@
/**
* Debugging each language's `fixtures/` project the way the editor does: a breakpoint on the
* entry point's "Set a breakpoint here" line, Debug from the file's context, then the paused
* frame, its variables, evaluation, Step Over and Resume, with the debugger the IDE installs.
* entry point's "Set a breakpoint here" line, Debug from the file's context (or a saved
* configuration, when the debugger is an explicit choice), then the paused frame, its
* variables, evaluation, Step Over and Resume, with the debugger the IDE installs (GDB:
* the toolchain's own, 14+ for its DAP interpreter).
*/
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
@@ -33,6 +35,10 @@ interface Case {
setup?: (root: string) => void;
/** The IDE's tool that debugs it. */
debugger: string;
/** A system tool that ships with the toolchain (the IDE does not install it). */
system?: boolean;
/** A saved configuration to debug (the debugger is its explicit choice), instead of the file's context. */
configId?: string;
/** The entry point, debugged from its context. */
file: string;
/** A variable of the paused frame. */
@@ -89,6 +95,37 @@ const CASES: Record<string, Case> = {
variable: "library",
evaluate: ["library", /Library/],
},
// GDB's own DAP interpreter (GDB 14+), the configuration's explicit debugger choice:
// the C/C++ default stays lldb-dap. GDB 15.0.50.20240403-0ubuntu1 says `15`.
c: {
fixture: "c-app",
setup: (root) => execFileSync("make", ["app-c"], { cwd: root, stdio: "ignore" }),
debugger: "gdb",
system: true,
configId: "user:app-c",
file: "src/main.c",
variable: "value",
evaluate: ["print value", / = [0-9]/],
},
cpp: {
fixture: "c-app",
setup: (root) => execFileSync("make", ["app-cpp"], { cwd: root, stdio: "ignore" }),
debugger: "gdb",
system: true,
configId: "user:app-cpp",
file: "src/main.cpp",
variable: "line",
evaluate: ["print line", / = /],
},
php: {
fixture: "php-app",
setup: (root) =>
execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }),
debugger: "php-debug",
file: "bin/quote.php",
variable: "$shipment",
evaluate: ["count($shipment)", /^3$/],
},
};
let deactivate: (() => void) | null = null;
@@ -205,7 +242,12 @@ const pausedOrEnded = () => {
async function smoke(language: string) {
const c = CASES[language]!;
const root = await open(c);
await install(c.debugger);
if (c.system) {
// GDB ships with the toolchain (14+ for its DAP interpreter); the IDE does not install it.
execFileSync(c.debugger, ["--version"], { stdio: "pipe" });
} else {
await install(c.debugger);
}
const path = join(root, c.file);
const content = readFileSync(path, "utf8");
@@ -213,7 +255,13 @@ async function smoke(language: string) {
const line = marker + 1;
addBreakpoint(path, line);
await executeAction("run.debugContext", { path, content });
if (c.configId) {
const config = (await call(commands.runConfigs())).find((cfg) => cfg.id === c.configId);
expect(config, c.configId).toBeTruthy();
await executeAction("debug.startConfig", config);
} else {
await executeAction("run.debugContext", { path, content });
}
if (c.request) void requestWhenListening(c.request);
const s = await until("the breakpoint", pausedOrEnded, 5 * 60_000);
expect(s.stopReason, `${s.stopDescription}\n${shown()}`).toBe("breakpoint");
@@ -227,6 +275,12 @@ async function smoke(language: string) {
const value = await evaluate(c.evaluate[0], "repl", s);
expect(value.value, c.evaluate[0]).toMatch(c.evaluate[1]);
if (c.debugger === "gdb") {
const expression = await evaluate(c.variable, "watch", s);
expect(expression.value).toBeTruthy();
const environment = await evaluate("environment_ok", "watch", s);
expect(environment.value).toBe("1");
}
// Python 3.12 reports a conditional expression's line twice: Step Over may stop on it
// (and its breakpoint) again.
+13
View File
@@ -122,6 +122,19 @@ const CASES: Record<string, Case> = {
file: "LibraryTest.kt",
},
},
php: {
fixture: "php-app",
setup: (root) =>
execFileSync("composer", ["install", "--no-interaction", "--no-progress"], { cwd: root, stdio: "ignore" }),
run: { config: "composer:quote", prints: "Bluebird Electronics: VAT $34.20, clearance fee $5.00" },
tests: {
config: "phpunit",
failing: (c) => env(c, { OPENIDEA_FAIL: "1" }),
passed: 13,
failed: "it intentionally fails (set OPENIDEA_FAIL=1)",
file: "ThresholdCustomsTest.php",
},
},
};
let server: Server | null = null;
+5 -3
View File
@@ -1,6 +1,8 @@
fn main() {
let root =
dunce::canonicalize(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap()).expect("worktree root");
// Read when the script runs: a path compiled into the script would outlive a move of the checkout.
let manifest_dir =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"));
let root = dunce::canonicalize(manifest_dir.parent().unwrap()).expect("worktree root");
println!("cargo:rustc-env=OPENIDEA_WORKTREE_ROOT={}", root.display());
// Windows (MSVC): link the Common-Controls v6 manifest into every binary of
// this package, not only the app's resources, so test executables such as
@@ -9,7 +11,7 @@ fn main() {
let mut windows = tauri_build::WindowsAttributes::new();
if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") {
windows = tauri_build::WindowsAttributes::new_without_app_manifest();
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("windows-app-manifest.xml");
let manifest = manifest_dir.join("windows-app-manifest.xml");
println!("cargo:rerun-if-changed={}", manifest.display());
println!("cargo:rustc-link-arg=/MANIFEST:EMBED");
println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display());
+1
View File
@@ -501,6 +501,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_install_dir,
plugin::plugin_grant,
plugin::plugin_set_capability,
plugin::plugin_revoke_net_host,
plugin::plugin_link,
plugin::plugin_unlink,
plugin::plugin_assets,
+8
View File
@@ -235,6 +235,14 @@ pub async fn plugin_set_capability(
blocking(move || host.set_capability(&id, &capability, granted)).await
}
/// Revokes one host remembered through net:ask.
#[tauri::command]
#[specta::specta]
pub async fn plugin_revoke_net_host(state: State<'_, AppState>, id: String, host: String) -> IdeResult<PluginInfo> {
let plugins = state.plugins.clone();
blocking(move || plugins.revoke_net_host(&id, &host)).await
}
/// Links a development plugin folder (`openidea-plugin link` does the same from a terminal).
#[tauri::command]
#[specta::specta]
+24 -7
View File
@@ -413,9 +413,10 @@ fn config_endpoints<R: tauri::Runtime>(app: &AppHandle<R>) -> Vec<String> {
/// The endpoints tried in order: `source` first, the other source when it fails or has
/// no manifest. Bundles are signed, so either source's manifest and downloads verify.
/// No official endpoint (a build without updates) means no mirror either.
fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String> {
if official.is_empty() {
/// No official endpoint (a build without updates) means no mirror either. Nightly builds
/// aren't mirrored: the mirror's manifest would move them to the stable version.
fn source_endpoints(official: Vec<String>, source: UpdateSource, nightly: bool) -> Vec<String> {
if official.is_empty() || nightly {
return official;
}
let mirror = vec![CNB_ENDPOINT.to_string()];
@@ -425,6 +426,10 @@ fn source_endpoints(official: Vec<String>, source: UpdateSource) -> Vec<String>
}
}
fn is_nightly(version: &str) -> bool {
version.contains("-nightly.")
}
fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plugin_updater::Updater>> {
use tauri_plugin_updater::UpdaterExt;
if !updates_configured(app) {
@@ -432,7 +437,7 @@ fn updater(app: &AppHandle, source: UpdateSource) -> IdeResult<Option<tauri_plug
}
let endpoints = match update_endpoint_env() {
Some(url) => vec![url],
None => source_endpoints(config_endpoints(app), source),
None => source_endpoints(config_endpoints(app), source, is_nightly(&app.package_info().version.to_string())),
};
let endpoints = endpoints
.iter()
@@ -646,10 +651,22 @@ mod tests {
#[test]
fn chosen_source_is_tried_first_and_the_other_after_it() {
let github = "https://github.com/l0ng-ai/openidea/releases/latest/download/latest.json".to_string();
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Github), [github.clone(), CNB_ENDPOINT.into()]);
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb), [CNB_ENDPOINT.into(), github]);
assert_eq!(
source_endpoints(vec![github.clone()], UpdateSource::Github, false),
[github.clone(), CNB_ENDPOINT.into()]
);
assert_eq!(source_endpoints(vec![github.clone()], UpdateSource::Cnb, false), [CNB_ENDPOINT.into(), github]);
// A build without an update endpoint stays without one.
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb).is_empty());
assert!(source_endpoints(Vec::new(), UpdateSource::Cnb, false).is_empty());
}
#[test]
fn nightly_builds_update_from_their_own_manifest_whatever_the_source() {
let nightly = "https://github.com/l0ng-ai/openidea/releases/download/nightly/latest.json".to_string();
assert_eq!(source_endpoints(vec![nightly.clone()], UpdateSource::Cnb, true), vec![nightly.clone()]);
assert_eq!(source_endpoints(vec![nightly.clone()], UpdateSource::Github, true), [nightly]);
assert!(is_nightly("0.1.40-nightly.202611081417"));
assert!(!is_nightly("0.1.40"));
}
#[test]
+13 -1
View File
@@ -1108,6 +1108,8 @@ export const commands = {
pluginGrant: (id: string, consent: Consent) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_grant", { id, consent })),
/** Grants or revokes one declared capability. */
pluginSetCapability: (id: string, capability: string, granted: boolean) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_set_capability", { id, capability, granted })),
/** Revokes one host remembered through net:ask. */
pluginRevokeNetHost: (id: string, host: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_revoke_net_host", { id, host })),
/** Links a development plugin folder (`openidea-plugin link` does the same from a terminal). */
pluginLink: (path: string) => typedError<PluginInfo, IdeError>(__TAURI_INVOKE("plugin_link", { path })),
pluginUnlink: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("plugin_unlink", { id })),
@@ -6693,6 +6695,11 @@ export type PluginInfo = {
hasCode: boolean,
/** Declared capabilities with risk and grant state. */
capabilities: CapabilityInfo[],
/**
* HTTPS hosts the user allowed through `net:ask` ("always allow"),
* none of them declared by the manifest; each can be revoked.
*/
netHosts: string[],
/** What it contributes while live (point + label). */
contributions: ContributionInfo[],
/** `activation` events of the manifest. */
@@ -7112,7 +7119,12 @@ export type ProjectStructureSettings = {
* What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
* `ui.input-multiline`, `ui.confirm`).
*/
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null };
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null } |
/**
* The host's own question under `net:ask` (never plugin-initiated): the
* plugin reached an HTTPS host its manifest does not list.
*/
{ kind: "netPermission"; host: string; sessionOnly: boolean };
export type ProxyKind = "http" | "socks";
+11
View File
@@ -5,6 +5,7 @@
*/
import { writeClipboardText } from "@/platform/clipboard";
import { pasteEditorClipboard } from "./cm/clipboard";
import type { ReactNode } from "react";
import { EditorSelection } from "@codemirror/state";
import { create } from "zustand";
import { setContext } from "@/platform/context";
@@ -211,6 +212,16 @@ export function tabIcon(input: EditorInput, kinds: EditorKinds): string {
}
}
/** A kind's trailing marker (status dot …); `null` when it has none. */
export function tabTrailing(input: EditorInput, kinds: EditorKinds): ReactNode {
const kind = kinds.get(input.kind);
try {
return kind?.trailing?.(input) ?? null;
} catch {
return null;
}
}
export class EditorController implements SlotHost {
manager: DocumentManager;
readonly slots = new Map<string, EditorSlot>();
+18 -2
View File
@@ -46,7 +46,15 @@ import {
type TabItem,
type TabsDnd,
} from "@/ui";
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
import {
editorController,
isTextInput,
tabIcon,
tabTitle,
tabTrailing,
useTabs,
type VirtualInputData,
} from "../controller";
import { useDocMeta } from "../model/document";
import { inputDirty } from "../model/customDocuments";
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
@@ -193,6 +201,8 @@ function PaneView({ paneId }: { paneId: string }) {
: names.filter((n) => n === name).length > 1 && t.input.path
? basename(dirname(t.input.path))
: null;
const kindMark = tabTrailing(t.input, kinds);
const pin = t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : null;
return {
id: t.id,
label: (
@@ -213,7 +223,13 @@ function PaneView({ paneId }: { paneId: string }) {
color: colors[i] || undefined,
tint: tints[i] || undefined,
tooltip: virtualTooltip(t.input) ?? (showFullPathOnHover ? (t.input.path ?? name) : undefined),
trailing: t.pinned ? <Icon name="pin" size={11} className={styles.pin} label="Pinned" /> : undefined,
trailing:
kindMark || pin ? (
<>
{kindMark}
{pin}
</>
) : undefined,
};
});
}, [
+25
View File
@@ -0,0 +1,25 @@
/** The tab strip's agent status indicator: spinner while the task is busy, dot once done (#2233). */
import { Badge, Spinner, tooltipAttrs } from "@/ui";
import { isBusy, statusLabel } from "./model";
import { useAgents } from "./store";
export function TaskStatusMark({ task }: { task: string }) {
const status = useAgents((s) => s.tasks.get(task)?.status);
if (!status) return null;
const busy = isBusy(status);
const label = statusLabel(status);
return (
<span {...tooltipAttrs(label, undefined, true)} style={{ display: "inline-flex", alignItems: "center" }}>
{busy ? (
<Spinner size={10} variant="spokes" label={label} />
) : (
<Badge
dot
tone={status === "ready" ? "success" : status === "failed" || status === "limited" ? "danger" : "neutral"}
>
{label}
</Badge>
)}
</span>
);
}
+4
View File
@@ -3,6 +3,7 @@
* Agents tool window lists tasks by what they need from the user, a task opens as an
* editor tab (conversation + changed files), and New Task is an editor page.
*/
import { createElement } from "react";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { executeAction } from "@/platform/actions";
@@ -22,6 +23,7 @@ import { HOME_KIND, REGISTRY_PAGE, SESSION_KIND, TASK_DRAG, TOOL_WINDOW } from "
import { sendPaths, sendSelection } from "./references";
import { focusedSession } from "./sessionFocus";
import { connect, disconnect, openHome, useAgents } from "./store";
import { TaskStatusMark } from "./TaskStatusMark";
import { taskActions } from "./taskActions";
import { replyActions } from "./replyActions";
@@ -88,6 +90,8 @@ export function activate(): () => void {
return useAgents.getState().tasks.get(data.task)?.title ?? data.title ?? "Agent Task";
},
icon: () => "toolwindow-agents",
// Busy / done marker in the tab's trailing slot (#2233).
trailing: (input) => createElement(TaskStatusMark, { task: (input.data as { task: string }).task }),
// Tabs of tasks deleted meanwhile are closed when the tasks load (`connect`).
restorable: true,
// Rebuilding a long conversation on every switch back is slow.
+1 -1
View File
@@ -297,7 +297,7 @@ function onEvent(e: AgentEvent) {
case "task": {
const prev = s.tasks.get(e.task.id);
useAgents.setState({ tasks: new Map(s.tasks).set(e.task.id, e.task) });
if (prev && prev.title !== e.task.title) notifyTabTitlesChanged();
if (prev && (prev.title !== e.task.title || prev.status !== e.task.status)) notifyTabTitlesChanged();
if (e.task.status === "archived") setUnread(e.task.id, false);
else if (attentionChange(prev?.status, e.task.status)) markUnread(e.task.id);
notifyChange(prev, e.task);
@@ -403,6 +403,68 @@ describe("Check for Updates", () => {
});
});
describe("automatic nightly update prompts", () => {
const DAY = 24 * 60 * 60 * 1000;
const update = (currentVersion: string): UpdateInfo => ({
version: "0.1.41",
currentVersion,
notes: null,
date: null,
});
const startupCheck = async (currentVersion: string) => {
notes = [];
await notifyUpdate(update(currentVersion));
return notes.length;
};
beforeEach(() => {
localStorage.clear();
vi.useFakeTimers({ toFake: ["Date"], now: 10 * DAY });
});
afterEach(() => vi.useRealTimers());
it("leaves stable prompts to the backend announcement policy", async () => {
expect(await startupCheck("0.1.40")).toBe(1);
expect(await startupCheck("0.1.40")).toBe(1);
});
it("asks a nightly build at most once a day", async () => {
const nightly = "0.1.41-nightly.202611081417";
expect(await startupCheck(nightly)).toBe(1);
vi.setSystemTime(11 * DAY - 1);
expect(await startupCheck(nightly)).toBe(0);
vi.setSystemTime(11 * DAY);
expect(await startupCheck(nightly)).toBe(1);
});
it("does not prompt twice a day for periodic nightly checks, but always answers a manual check", async () => {
const nightly = "0.1.41-nightly.202611081417";
const dispose = installUpdateChecks();
await tick();
try {
const available = updateListeners.get("updateAvailable")!;
available({ update: update(nightly) });
await tick();
expect(notes).toHaveLength(1);
vi.setSystemTime(10 * DAY + 4 * 60 * 60 * 1000);
available({ update: { ...update(nightly), version: "0.1.42-nightly.202611090200" } });
await tick();
expect(notes).toHaveLength(1);
const done = checkForUpdates();
check.resolve(ok({ enabled: true, update: update(nightly) }));
await done;
expect(notes.at(-1)).toMatchObject({ title: "OpenIDEA 0.1.41 is available", userInitiated: true });
notes = [];
vi.setSystemTime(11 * DAY);
available({ update: { ...update(nightly), version: "0.1.42-nightly.202611091400" } });
await tick();
expect(notes).toHaveLength(1);
} finally {
dispose();
}
});
});
describe("updateSource", () => {
const withSettings = (updateSource: string | undefined, locale: string | undefined) =>
useProject.setState({ settings: { general: { updateSource }, appearance: { locale } } as unknown as Settings });
+12
View File
@@ -127,6 +127,17 @@ async function runCheck() {
}
}
const NIGHTLY_PROMPT_KEY = "openidea.update.nightlyPromptedAt";
const NIGHTLY_PROMPT_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Nightly builds come out more often than anyone wants to be asked: at most one prompt a day. */
function nightlyPromptDue(now = Date.now()): boolean {
const last = Number(localStorage.getItem(NIGHTLY_PROMPT_KEY));
if (now - last < NIGHTLY_PROMPT_INTERVAL_MS) return false;
localStorage.setItem(NIGHTLY_PROMPT_KEY, String(now));
return true;
}
/** Start one backend schedule after this window can receive its notifications. */
export function installUpdateChecks(): () => void {
let disposed = false;
@@ -154,6 +165,7 @@ export async function notifyUpdate(update: UpdateInfo, userInitiated = false): P
);
if (!claimed) return;
}
if (!userInitiated && update.currentVersion.includes("-nightly.") && !nightlyPromptDue()) return;
notifications().notify({
level: "info",
title: t("app.update.available", { version: update.version }),
+64 -4
View File
@@ -253,7 +253,13 @@ describe("run config → launch config", () => {
it("delve: debug mode for run, test mode for test", () => {
const run = launchConfigFor(rc({ kind: "go", command: "go", args: ["run", "."], debugAdapter: "delve" }), "/p")!;
expect(run.program).toBe("/p");
expect(extra(run)).toEqual({ mode: "debug", __goToolArgs: [], __goUseTags: true });
expect(extra(run)).toMatchObject({
mode: "debug",
__goTargets: ["."],
__goExecutable: "go",
__goToolArgs: [],
__goUseTags: true,
});
const sub = launchConfigFor(
rc({ kind: "go", command: "go", args: ["run", "./cmd/api"], debugAdapter: "delve" }),
"/p",
@@ -263,8 +269,8 @@ describe("run config → launch config", () => {
rc({ kind: "go", command: "go", args: ["test", "./..."], debugAdapter: "delve" }),
"/p",
)!;
expect(extra(test)).toEqual({ mode: "test", __goToolArgs: [], __goUseTags: true });
expect(test.program).toBe("/p");
expect(extra(test)).toMatchObject({ mode: "test", __goTargets: ["./..."], __goToolArgs: [], __goUseTags: true });
expect(test.program).toBe("/p/...");
// A gutter test / subtest: the package, and `-run` for the test binary.
const one = launchConfigFor(
rc({
@@ -299,7 +305,7 @@ describe("run config → launch config", () => {
});
});
it("native programs under CodeLLDB / lldb-dap: program, args, cwd, env", () => {
it("native programs under CodeLLDB / lldb-dap / GDB: program, args, cwd, env", () => {
const run = rc({ command: "./build/app", args: ["-v"], cwd: "c", env: { K: "v" } });
const lldb = launchConfigFor({ ...run, debugAdapter: "codelldb" }, "/p")!;
expect(lldb).toMatchObject({
@@ -314,6 +320,16 @@ describe("run config → launch config", () => {
expect(dap).toMatchObject({ adapter: "lldb-dap", program: "/p/c/build/app" });
// lldb-dap takes `KEY=VALUE` strings.
expect(extra(dap)).toEqual({ env: ["K=v"] });
// GDB's DAP launch takes the environment as an object, like the default flow.
const gdb = launchConfigFor({ ...run, debugAdapter: "gdb" }, "/p")!;
expect(gdb).toMatchObject({
adapter: "gdb",
program: "/p/c/build/app",
args: ["-v"],
cwd: "/p/c",
env: { K: "v" },
});
expect(gdb.extraJson).toBeNull();
});
it.each(["native", "cmake-application"])(
@@ -419,6 +435,50 @@ it("netcoredbg launches the evaluated DLL with resolved profile arguments, env a
expect(launchConfigFor({ ...c, params: {} }, "/repo")).toBeNull();
});
describe("Go debug target resolution inputs", () => {
it("preserves import paths and multiple source files for Go to resolve", () => {
const imported = launchConfigFor(
rc({ kind: "go", command: "/sdk/bin/go", args: ["test", "example.com/app/calc"], debugAdapter: "delve" }),
"/p",
)!;
expect(imported.program).toBe("example.com/app/calc");
expect(extra(imported)).toMatchObject({
__goTargets: ["example.com/app/calc"],
__goExecutable: "/sdk/bin/go",
__goBuildDir: "/p",
});
const files = launchConfigFor(
rc({ kind: "go", command: "go", args: ["run", "main.go", "helper.go", "hello"], debugAdapter: "delve" }),
"/p",
)!;
expect(files.args).toEqual(["hello"]);
expect(extra(files).__goTargets).toEqual(["main.go", "helper.go"]);
const packages = launchConfigFor(
rc({ kind: "go", command: "go", args: ["build", "./a", "./b"], debugAdapter: "delve" }),
"/p",
)!;
expect(extra(packages).__goTargets).toEqual(["./a", "./b"]);
});
it("keeps a typed recursive package target and the module's selected compiler", () => {
const launch = launchConfigFor(
rc({
kind: "go",
command: "./sdk/bin/go",
args: ["test", "./..."],
debugAdapter: "delve",
params: { goKind: "test", scope: "package", target: "./...", module: "backend" },
}),
"/p",
)!;
expect(extra(launch)).toMatchObject({
__goTargets: ["./..."],
__goBuildDir: "/p/backend",
__goExecutable: "/p/backend/sdk/bin/go",
});
});
});
it("raw and typed Go debug configurations inherit custom tags unless explicitly disabled", () => {
for (const typed of [false, true]) {
for (const useTags of [undefined, "true", "false"]) {
@@ -1,6 +1,13 @@
import { describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
const debugEvaluateIn = vi.hoisted(() =>
vi.fn(async () => ({
status: "ok" as const,
data: { name: "result", value: "4", typeName: "int", variablesReference: 0, evaluateName: null },
})),
);
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = <T,>(data: T) => Promise.resolve({ status: "ok" as const, data });
@@ -8,6 +15,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
...real,
commands: {
...real.commands,
debugEvaluateIn,
debugScopes: () => ok([{ name: "Locals", variablesReference: 1, expensive: false }]),
debugVariables: () =>
ok([
@@ -31,6 +39,8 @@ vi.mock("@/workbench/confirmTerminate", () => ({ confirmTerminate }));
import type { RunConfig } from "@/platform/ipc";
import { useDebug, type DebugSession } from "../state";
import { DebugTabs, DebugToolWindow } from "../ui/DebugToolWindow";
import { DebuggerOverlays } from "../ui/Overlays";
import { useOverlays } from "../ui/popoverStore";
const session: DebugSession = {
id: 1,
@@ -53,6 +63,26 @@ const session: DebugSession = {
};
describe("debug tool window", () => {
it.each(["gdb", "debugpy"])("evaluates expressions in the variable field and dialog under %s", async (adapter) => {
debugEvaluateIn.mockClear();
useDebug.setState({ sessions: { 1: { ...session, adapter } }, active: 1 });
const { unmount } = render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Threads & Variables" }));
const field = screen.getByPlaceholderText(/Evaluate expression/);
fireEvent.change(field, { target: { value: "count + 1" } });
fireEvent.keyDown(field, { key: "Enter" });
const context = adapter === "gdb" ? "watch" : "repl";
await vi.waitFor(() => expect(debugEvaluateIn).toHaveBeenCalledWith(1, 7, "count + 1", context));
unmount();
debugEvaluateIn.mockClear();
useOverlays.setState({ evaluate: { expression: "count + 1" } });
const dialog = render(<DebuggerOverlays />);
fireEvent.click(screen.getByRole("button", { name: "Evaluate" }));
await vi.waitFor(() => expect(debugEvaluateIn).toHaveBeenCalledWith(1, 7, "count + 1", context));
dialog.unmount();
useOverlays.setState({ evaluate: null });
});
it("offers Hot Reload and Hot Restart for Dart and Flutter sessions only", () => {
const hot = () => ["debug.hotReload", "debug.hotRestart"].map((name) => !!screen.queryByRole("button", { name }));
useDebug.setState({ sessions: { 1: { ...session, status: "running" } }, active: 1 });
+20 -8
View File
@@ -16,7 +16,7 @@ function join(base: string, p: string) {
/** A command path with a directory part resolved against `cwd`; bare names stay for PATH lookup. */
function resolveCommand(cmd: string, cwd: string) {
return cmd.includes("/") ? join(cwd, cmd) : cmd;
return /[\\/]/.test(cmd) ? join(cwd, cmd) : cmd;
}
function splitAtDashDash(args: readonly string[]): [string[], string[]] {
@@ -225,6 +225,9 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
dlvCwd: module,
...(p.scope === "file" ? { __goFiles: target.split("|").map((f) => join(module, f.trim())) } : {}),
__goToolArgs: toolArgs,
...(p.scope === "file" ? {} : { __goTargets: [target] }),
__goBuildDir: module,
__goExecutable: resolveCommand(rc.command, module),
__goUseTags: p.useTags !== "false",
...(p.outputDir ? { output: join(root, `${p.outputDir}/go-debug`) } : {}),
});
@@ -261,9 +264,11 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
}
const selection =
test &&
/^-(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(a);
if (test && /^-(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
testFlags.push(a.replace(/^-/, "-test."));
/^-(?:test\.)?(run|skip|bench|benchtime|count|timeout|parallel|cpu|list|fuzz|fuzztime|fuzzminimizetime)(?:=(.*))?$/.exec(
a,
);
if (test && /^-(?:test\.)?(short|failfast|benchmem|fullpath|v)(?:=|$)/.test(a)) {
testFlags.push(a.startsWith("-test.") ? a : a.replace(/^-/, "-test."));
} else if (selection) {
const value = selection[2] ?? rest[++i];
if (value !== undefined) testFlags.push(`-test.${selection[1]}`, value);
@@ -271,18 +276,25 @@ function delveLaunch(rc: RunConfig, cwd: string, root: string): LaunchConfig {
if (test && (a === "-json" || a === "-c")) continue;
flags.push(a);
if (!a.includes("=") && !boolFlags.has(a) && rest[i + 1] !== undefined) flags.push(rest[++i]!);
} else if (!test && targets.length) {
} else if (
sub === "run" &&
targets.length &&
!(a.endsWith(".go") && targets.every((target) => target.endsWith(".go")))
) {
programArgs = rest.slice(i);
break;
} else targets.push(a);
}
const target = targets[0] ?? ".";
out.program = target.endsWith("/...") ? cwd : join(cwd, target);
out.program = !target.startsWith(".") && !isAbsolute(target) && !target.endsWith(".go") ? target : join(cwd, target);
out.args = [...testFlags, ...programArgs];
out.extraJson = JSON.stringify({
mode: test ? "test" : "debug",
...(flags.length ? { buildFlags: formatArgs(flags) } : {}),
__goToolArgs: flags,
__goTargets: targets.length ? targets : ["."],
__goBuildDir: cwd,
__goExecutable: resolveCommand(rc.command, cwd),
__goUseTags: rc.params?.useTags !== "false",
});
return out;
@@ -306,7 +318,7 @@ function godotLaunch(rc: RunConfig, cwd: string): LaunchConfig {
return { ...base(rc, "godot", project), extraJson: JSON.stringify({ project, scene: godotScene(rc.args) }) };
}
/** A native program under CodeLLDB or lldb-dap: program, args, cwd, env. */
/** A native program under CodeLLDB, lldb-dap or GDB: program, args, cwd, env. */
function nativeLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig {
const out: LaunchConfig = { ...base(rc, adapter, cwd), program: resolveCommand(rc.command, cwd), args: [...rc.args] };
// lldb-dap takes the environment as `KEY=VALUE` strings.
@@ -408,7 +420,7 @@ export function launchConfigFor(rc: RunConfig, root: string, php: string | null
if (adapter === "dart") {
return rc.params?.request === "attach" ? flutterAttach(rc, cwd, adapter) : flutterLaunch(rc, cwd, adapter);
}
if (adapter === "codelldb" || adapter === "lldb-dap") {
if (adapter === "codelldb" || adapter === "lldb-dap" || adapter === "gdb") {
return rc.kind === "cargo" ? cargoLaunch(rc, cwd, adapter) : nativeLaunch(rc, cwd, adapter);
}
if (adapter === "debugpy") return debugpyLaunch(rc, cwd);
+5 -1
View File
@@ -512,7 +512,11 @@ function VariablesPane({ session }: { session: DebugSession }) {
const e = expression.trim();
if (!e) return;
try {
setLastEval({ expression: e, result: await evaluate(e, "repl", session), error: null });
setLastEval({
expression: e,
result: await evaluate(e, session.adapter === "gdb" ? "watch" : "repl", session),
error: null,
});
} catch (err) {
setLastEval({ expression: e, result: null, error: err instanceof Error ? err.message : String(err) });
}
+1 -1
View File
@@ -706,7 +706,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
setHistory(readHistory("evaluate"));
historyPos.current = -1;
try {
const v: Variable = await evaluate(e, "repl", session ?? null);
const v: Variable = await evaluate(e, session?.adapter === "gdb" ? "watch" : "repl", session ?? null);
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: v, error: null });
} catch (err) {
setResult({ key: `eval:${e}:${Date.now()}`, kind: "eval", name: e, variable: null, error: errorText(err) });
+25 -1
View File
@@ -49,6 +49,7 @@ import {
reloadPlugin,
setAllInstalledEnabled,
setPluginCapability,
revokePluginNetHost,
setPluginEnabled,
uninstallPlugin,
updateAllPlugins,
@@ -376,7 +377,7 @@ function ProgramPermission({ plugin, cap, disabled }: { plugin: PluginInfo; cap:
}
function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) {
if (plugin.capabilities.length === 0) {
if (plugin.capabilities.length === 0 && plugin.netHosts.length === 0) {
return (
<div className={styles.muted}>
<T>No special permissions.</T>
@@ -402,10 +403,33 @@ function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean })
/>
</CapabilityRow>
))}
{plugin.netHosts.map((host) => (
<NetHostRow key={host} plugin={plugin} host={host} disabled={busy || !confirmed} />
))}
</ul>
);
}
/** A host the user allowed through `net:ask` ("always"); removing it asks again next time. */
function NetHostRow({ plugin, host, disabled }: { plugin: PluginInfo; host: string; disabled: boolean }) {
return (
<li className={styles.capabilityItem}>
<div className={styles.capabilityHeader}>
<span className={styles.mono}>{`net:${host}`}</span>
<Badge tone={RISK_LABEL.medium.tone}>{RISK_LABEL.medium.label}</Badge>
</div>
<p className={styles.capabilityDescription}>
{tx("Allowed when the plugin asked at first use. Remove it to be asked again.")}
</p>
<div className={styles.programPermission}>
<Button disabled={disabled} onClick={() => void revokePluginNetHost(plugin.id, host)}>
{tx("Remove")}
</Button>
</div>
</li>
);
}
function Homepage({ value }: { value: string }) {
let isWebUrl = false;
try {
@@ -10,6 +10,7 @@ import { useProject } from "@/platform/project";
import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes";
import { ContextMenuHost, hideContextMenu } from "@/ui";
import { InputDialogsHost } from "@/workbench/inputDialogs";
import { MessagesHost } from "@/workbench/messages";
import {
onEndpointsChanged,
provideNotificationService,
@@ -74,6 +75,7 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn
error: null,
hasCode: true,
capabilities: [],
netHosts: [],
contributions: [],
activation: [],
warnings: [],
@@ -640,6 +642,115 @@ describe("plugin prompts", () => {
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("2", null));
});
it("asks about a net:ask host with allow once, always allow and deny", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
usePlugins.setState({ plugins: [plugin({ id: "p", name: "Relay" })] });
render(<MessagesHost />);
const ask = (promptId: string) =>
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId,
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false },
});
});
ask("9");
expect(screen.getByText("Allow Relay to connect to api.example.com?")).toBeDefined();
expect(screen.getByText(/The host is not in the plugin's declared permissions\./)).toBeDefined();
expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Always allow" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("9", ["always"]));
ask("10");
await user.click(await screen.findByRole("button", { name: "Allow once" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("10", ["once"]));
ask("11");
await user.click(await screen.findByRole("button", { name: "Deny" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("11", null));
});
it("offers session permission and deny for a remote net:ask prompt", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<MessagesHost />);
const ask = (promptId: string) =>
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId,
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: true },
});
});
ask("session-net");
expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull();
expect(screen.queryByRole("button", { name: "Allow once" })).toBeNull();
expect(screen.getAllByRole("button")).toHaveLength(2);
expect(screen.getByText(/This allows api.example.com until the remote session ends\./)).toBeDefined();
expect(screen.queryByText(/revoke it any time/)).toBeNull();
expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus();
await user.click(screen.getByRole("button", { name: "Allow for this session" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("session-net", ["session"]));
ask("denied-session-net");
await user.click(await screen.findByRole("button", { name: "Deny" }));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("denied-session-net", null));
});
it("refuses network prompts in a hidden window and closes them when hidden", async () => {
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<MessagesHost />);
const visibility = vi.spyOn(document, "visibilityState", "get");
const ask = (promptId: string) =>
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId,
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false },
});
});
try {
visibility.mockReturnValue("hidden");
ask("hidden");
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("hidden", null));
expect(screen.queryByRole("dialog")).toBeNull();
visibility.mockReturnValue("visible");
ask("visible");
expect(screen.getByRole("button", { name: "Deny" })).toHaveFocus();
visibility.mockReturnValue("hidden");
act(() => document.dispatchEvent(new Event("visibilitychange")));
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("visible", null));
expect(screen.queryByRole("dialog")).toBeNull();
} finally {
visibility.mockRestore();
}
});
it("dismisses a network prompt without allowing the request", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
render(<MessagesHost />);
act(() => {
handlePluginEvent({
type: "promptRequested",
pluginId: "p",
promptId: "dismissed-net",
prompt: { kind: "netPermission", host: "api.example.com", sessionOnly: false },
});
});
await user.keyboard("{Escape}");
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("dismissed-net", null));
});
it("asks multi-line text and confirms on Ctrl+Enter", async () => {
const user = userEvent.setup();
const answerPrompt = vi.fn().mockResolvedValue(undefined);
@@ -1232,6 +1343,34 @@ describe("marketplace", () => {
});
});
it.each(["api.example.com", "::1", "ask"])(
"revokes remembered host %s separately from capabilities",
async (host) => {
const user = userEvent.setup();
const base = createMemoryPluginsBackend([
plugin({
id: "p",
name: "P",
netHosts: [host, "relay.example.org"],
capabilities: [
{ id: "net:ask", risk: "medium", description: "Ask to connect", optional: true, granted: true },
],
}),
]);
const revokeNetHost = vi.fn((id: string, host: string) => base.revokeNetHost(id, host));
setPluginsBackend({ ...base, revokeNetHost });
render(<PluginsPage />);
await screen.findByText("net:relay.example.org");
expect(screen.getAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 2 : 1);
expect(screen.getByText("net:relay.example.org")).toBeInTheDocument();
const rows = screen.getAllByRole("button", { name: "Remove" });
await user.click(rows[0]!);
await waitFor(() => expect(revokeNetHost).toHaveBeenCalledWith("p", host));
await waitFor(() => expect(screen.queryAllByText(`net:${host}`)).toHaveLength(host === "ask" ? 1 : 0));
expect(screen.getByText("net:relay.example.org")).toBeInTheDocument();
},
);
it("offers to roll back an update", async () => {
const base = createMemoryPluginsBackend([
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
+10
View File
@@ -58,6 +58,7 @@ export interface PluginsBackend {
program(pluginId: string, program: string, scope: "global" | "project"): Promise<string>;
pickProgram(): Promise<string | null>;
setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>;
revokeNetHost(id: string, host: string): Promise<PluginInfo>;
/** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */
assets(pluginId: string, prefix: string): Promise<[string, string][]>;
/** `language` / `file` / `view` activation events. */
@@ -216,6 +217,7 @@ export const tauriPluginsBackend: PluginsBackend = {
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)),
revokeNetHost: (id, host) => call(commands.pluginRevokeNetHost(id, host)),
assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)),
activationEvent: async (kind, value) => {
await call(commands.pluginActivationEvent(kind, value));
@@ -420,6 +422,7 @@ function samplePlugins(): PluginInfo[] {
enabled: true,
error: null,
capabilities: [],
netHosts: [],
contributions: [],
activation: [],
warnings: [],
@@ -588,10 +591,17 @@ export function createMemoryPluginsBackend(
setCapability: async (id, capability, granted) => {
const p = find(id);
p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c));
if (!granted && capability === "net:ask") p.netHosts = [];
if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent";
emit({ type: "commandsChanged" });
return { ...p };
},
revokeNetHost: async (id, host) => {
const p = find(id);
p.netHosts = p.netHosts.filter((h) => h !== host);
emit({ type: "commandsChanged" });
return { ...p };
},
assets: async () => [],
activationEvent: async () => {},
catalog: async () => catalog.map((c) => ({ ...c })),
+5
View File
@@ -39,6 +39,11 @@ export function capabilityDescription(cap: CapabilityInfo): string {
"Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing",
);
}
if (cap.id === "net:ask") {
return tx(
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
);
}
if (cap.id.startsWith("net.http:")) {
const host = cap.id.slice(9);
return tx`Connect to ${host} over unencrypted HTTP (from the machine the project is on)`;
+59
View File
@@ -0,0 +1,59 @@
/**
* The host's own permission question (`net:ask`): a plugin with the
* capability reached an HTTPS host its manifest does not list. One dialog,
* one host: allow for this request, remember it, or refuse. Remembered
* local hosts appear in the plugin's permissions, where each can be revoked.
*/
import { tx } from "@/platform/i18n";
import { ask } from "@/workbench/messages";
import { usePlugins } from "./store";
/** Resolves with the host's answer key, or null when refused. */
export async function askNetPermission(
pluginId: string,
host: string,
sessionOnly: boolean,
signal: AbortSignal,
): Promise<string[] | null> {
if (document.visibilityState === "hidden") return null;
const controller = new AbortController();
const abort = () => controller.abort();
const visibilityChanged = () => {
if (document.visibilityState === "hidden") controller.abort();
};
signal.addEventListener("abort", abort);
document.addEventListener("visibilitychange", visibilityChanged);
if (signal.aborted) controller.abort();
const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
try {
const r = await ask({
title: tx`Allow ${name} to connect to ${host}?`,
body: [
tx("The host is not in the plugin's declared permissions."),
" ",
tx("This allows this exact host on any port; subdomains need separate permission."),
" ",
sessionOnly
? tx`This allows ${host} until the remote session ends.`
: tx`"Always allow" remembers ${host}; revoke it any time in the plugin's permissions.`,
],
buttons: sessionOnly
? [
{ id: "session", label: tx("Allow for this session") },
{ id: "deny", label: tx("Deny"), primary: true },
]
: [
{ id: "once", label: tx("Allow once") },
{ id: "always", label: tx("Always allow") },
{ id: "deny", label: tx("Deny"), primary: true },
],
cancel: false,
icon: "question",
signal: controller.signal,
});
return r.button === "session" || r.button === "once" || r.button === "always" ? [r.button] : null;
} finally {
signal.removeEventListener("abort", abort);
document.removeEventListener("visibilitychange", visibilityChanged);
}
}
+10 -3
View File
@@ -45,6 +45,7 @@ import { confirm } from "@/workbench/messages";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { editorContext } from "./editorContext";
import { pluginsBackend } from "./backend";
import { askNetPermission } from "./netAsk";
import { usePluginT } from "./pluginText";
import { setPluginOutput } from "./views/outputStore";
import { PLUGIN_LOG_EDITOR_KIND } from "./views/logEditor";
@@ -408,13 +409,19 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] |
return (await confirm({ title: prompt.title, message: prompt.message, ok: tx("OK"), signal, verbatim: true }))
? []
: null;
case "netPermission":
// The host's own question: answered before this (`answerPrompt`).
return null;
}
}
async function answerPrompt(promptId: string, prompt: Prompt) {
async function answerPrompt(promptId: string, prompt: Prompt, pluginId: string) {
const controller = new AbortController();
openPrompts.set(promptId, controller);
const answer = await askUser(prompt, controller.signal);
const answer =
prompt.kind === "netPermission"
? await askNetPermission(pluginId, prompt.host, prompt.sessionOnly, controller.signal)
: await askUser(prompt, controller.signal);
openPrompts.delete(promptId);
if (controller.signal.aborted) return;
await pluginsBackend()
@@ -599,7 +606,7 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
void openExternalUrl(e.url);
return true;
case "promptRequested":
void answerPrompt(e.promptId, e.prompt);
void answerPrompt(e.promptId, e.prompt, e.pluginId);
return true;
case "promptClosed":
openPrompts.get(e.promptId)?.abort();
+6
View File
@@ -1,5 +1,6 @@
/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */
import { create } from "zustand";
import { tx } from "@/platform/i18n";
import type {
ActiveTheme,
CapabilityInfo,
@@ -191,6 +192,11 @@ export function setPluginCapability(id: string, capability: string, granted: boo
);
}
export function revokePluginNetHost(id: string, host: string): Promise<boolean> {
const name = nameOf(id);
return withBusy(id, tx`Could not revoke ${host} for ${name}`, () => pluginsBackend().revokeNetHost(id, host));
}
export async function refreshCatalog(): Promise<void> {
try {
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
+2 -1
View File
@@ -66,6 +66,7 @@ const ADAPTERS: ReadonlyArray<{ id: string; label: string }> = [
{ id: "delve", label: "Delve (Go)" },
{ id: "codelldb", label: "CodeLLDB (Rust, C, C++)" },
{ id: "lldb-dap", label: "lldb-dap (C, C++, Swift)" },
{ id: "gdb", label: "GDB (C, C++)" },
{ id: "php-debug", label: "php-debug (PHP)" },
{ id: "rdbg", label: "rdbg (Ruby)" },
];
@@ -375,7 +376,7 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi
const adapterOptions: SelectOption[] = [
{ value: "", label: "None" },
...ADAPTERS.map((a) => ({ value: a.id, label: a.label })),
...ADAPTERS.map((a) => ({ value: a.id, label: tx(a.label) })),
];
if (config.debugAdapter && !ADAPTERS.some((a) => a.id === config.debugAdapter))
adapterOptions.push({
+4 -3
View File
@@ -222,6 +222,10 @@
min-width: 0;
flex: 1;
}
.testRow .testStatus {
width: var(--oi-tree-icon-size, 16px);
height: var(--oi-tree-icon-size, 16px);
}
.testName {
flex: 1;
min-width: 0;
@@ -242,9 +246,6 @@
font-size: var(--fs-ui);
}
.passed {
color: var(--success);
}
.failed {
color: var(--danger);
}
+9 -9
View File
@@ -72,15 +72,15 @@ function totalDuration(node: TestNode): number | undefined {
export function StatusIcon({ status }: { status: TestStatus | null }) {
switch (status) {
case "passed":
return <Icon name="check" size={13} className={styles.passed} label="Passed" />;
return <Icon name="test-passed" className={styles.testStatus} label="Passed" />;
case "failed":
return <Icon name="close" size={13} className={styles.failed} label="Failed" />;
return <Icon name="test-failed" className={styles.testStatus} label="Failed" />;
case "skipped":
return <Icon name="minus" size={13} className={styles.skipped} label="Ignored" />;
return <Icon name="test-ignored" className={styles.testStatus} label="Ignored" />;
case "running":
return <Spinner size={11} />;
return <Spinner size={16} variant="spokes" className={styles.testStatus} />;
default:
return <Icon name="clock" size={13} className={styles.skipped} label="Pending" />;
return <Icon name="test-pending" className={styles.testStatus} label="Pending" />;
}
}
@@ -225,17 +225,17 @@ export function TestTreeView({
</span>
<span className={styles.spacer} />
<IconButton
icon="check"
icon="test-show-passed"
size="small"
iconSize={13}
iconSize={16}
title="Show Passed"
pressed={showPassed}
onClick={() => toggle("showPassed")}
/>
<IconButton
icon="minus"
icon="test-show-ignored"
size="small"
iconSize={13}
iconSize={16}
title="Show Ignored"
pressed={showIgnored}
onClick={() => toggle("showIgnored")}
+15 -1
View File
@@ -6,6 +6,7 @@ import { ToolWindowHeader, useOwnToolWindowHeader } from "@/workbench/toolwindow
import { usePlatform } from "@/platform/platformInfo";
import { commands, isTauri } from "@/platform/ipc";
import {
Badge,
dropZoneAt,
IconButton,
Input,
@@ -13,6 +14,7 @@ import {
Popup,
registerDropTarget,
showContextMenu,
Spinner,
SplitGroup,
SplitHandle,
SplitPanel,
@@ -356,8 +358,19 @@ function GroupTabs({ group }: { group: string }) {
const focusRaf = useRef(0);
const items: TabItem[] = tabs.map((t) => {
const status = sessions[t.session]?.status;
const session = sessions[t.session];
const status = session?.status;
const dead = status === "exited" || status === "error";
// Running / done marker (#2233): a command at work spins, a finished one
// shows a filled dot, an idle shell at the prompt shows none.
const busy = !!session?.commandRunning || status === "starting";
const mark = busy ? (
<Spinner size={10} variant="spokes" label="Running" />
) : dead ? (
<Badge dot tone={status === "error" ? "danger" : "neutral"}>
{status === "error" ? "Failed" : "Exited"}
</Badge>
) : null;
return {
id: t.id,
label: tabTitle(t, sessions),
@@ -365,6 +378,7 @@ function GroupTabs({ group }: { group: string }) {
italic: dead,
color: dead ? "var(--fg-subtle)" : undefined,
tooltip: tabTooltip(t, sessions),
trailing: mark ?? undefined,
};
});
+16 -1
View File
@@ -1,4 +1,5 @@
{
"GDB (C, C++)": "GDB (C, C++)",
"Unknown error": "Unknown error",
"Retry Loading Conversation": "Retry Loading Conversation",
"No conversation history": "No conversation history",
@@ -814,5 +815,19 @@
"Set Docker Compose File Detection": "Set Docker Compose File Detection",
"Could not save Docker Compose file detection": "Could not save Docker Compose file detection",
"Compose Up": "Compose Up",
"Start the service at the caret, or all services in the active Docker Compose file": "Start the service at the caret, or all services in the active Docker Compose file"
"Start the service at the caret, or all services in the active Docker Compose file": "Start the service at the caret, or all services in the active Docker Compose file",
"Allow {name} to connect to {host}?": "Allow {name} to connect to {host}?",
"The host is not in the plugin's declared permissions.": "The host is not in the plugin's declared permissions.",
"\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.": "\"Always allow\" remembers {host}; revoke it any time in the plugin's permissions.",
"Allow once": "Allow once",
"Always allow": "Always allow",
"Remove": "Remove",
"Allowed when the plugin asked at first use. Remove it to be asked again.": "Allowed when the plugin asked at first use. Remove it to be asked again.",
"Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones": "Ask to connect to a host that is not on the list the first time it tries; you allow or refuse each host, and can revoke the allowed ones",
"This allows this exact host on any port; subdomains need separate permission.": "This allows this exact host on any port; subdomains need separate permission.",
"Could not revoke {host} for {name}": "Could not revoke {host} for {name}",
"host is not remembered for this plugin": "host is not remembered for this plugin",
"URL must name one exact host": "URL must name one exact host",
"Allow for this session": "Allow for this session",
"This allows {host} until the remote session ends.": "This allows {host} until the remote session ends."
}
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more