Skip to content

Feature: github-install#6

Merged
philcunliffe merged 5 commits into
masterfrom
integration/github-install
May 22, 2026
Merged

Feature: github-install#6
philcunliffe merged 5 commits into
masterfrom
integration/github-install

Conversation

@philcunliffe
Copy link
Copy Markdown
Contributor

Auto-generated by /feature-launch to host the integration branch for feature github-install.

Work beads file individual sub-PRs into this branch via the refinery feature-flow loop. When all work + review + ship beads complete, the ship formula flips this PR to ready-for-review.

See /feature-flow for the flow architecture.

feature-launch and others added 4 commits May 21, 2026 12:48
Extend src/core/plugin_install to support git sources. Parses GitHub URL
forms (https with optional .git/#ref, github: shorthand, git@github.com
SSH) plus generic git+/git:/ssh:/gitlab:/bitbucket:/file:// URLs through
a new pure git_source parser. Adds --ref and --path CLI flags to the
plugin install command; --ref conflicting with a URL #ref is rejected
as source_ambiguous, and --path is reserved as git_subdir_unsupported.

The fetch path clones with --filter=blob:none --no-checkout, checks out
the requested ref (or the remote's HEAD symref by default), resolves
HEAD to a commit, validates the manifest and entrypoint, refuses any
symlinks in the artifact tree, then copies into an atomic install
directory swap. Lock entries gain resolved_ref; the update probe runs
git ls-remote and flips update.available when the upstream commit moved.

Telemetry: the existing plugin.install span gains hyp_source_kind=git,
git_url_host/owner/repo/ref/resolved_ref, content_hash, and
manifest_hash. Child spans plugin.git.clone, plugin.git.checkout,
plugin.git.resolve_ref, plugin.artifact.validate, and
plugin.artifact.copy each carry status and error_kind. Git stderr is
redacted for credentials before being surfaced.

Tests cover URL parsing variants, the --ref/URL-fragment conflict, the
subdir reservation, entrypoint traversal rejection, symlink detection,
and content-hash stability. New hermetic smoke plugin_install_git_url
builds a local bare repo and exercises the install end-to-end through
the dispatcher.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bootKernel now reads plugin-lock.json, merges bundled and installed
manifest pools, resolves dependencies across the merged set, and
activates installed third-party plugins when the config names them.
Rejects boot with `installed_shadows_bundled` when an installed
plugin tries to shadow a bundled first-party name. Adds telemetry
attributes (installed_available, installed_failed,
installed_shadow_collisions, installed_selected) and a
plugin.installed_active log per installed-and-selected plugin.

Threads installed manifest metadata into the cross-plugin config
validator via a new mergeInstalledManifestsIntoKnown helper, so
third-party plugin sections are no longer flagged plugin_unknown
and their provides/requires participate in sink-pair and
capability-ambiguity checks. Updates hyp config validate, hyp init
--from-file, and daemon status to feed the merged map in.

Adds boot-installed unit tests covering merged pool, dep resolution
over bundled+installed, shadow rejection, and config-validator
behavior, plus a hermetic plugin_boot_installed smoke that installs
a git-fixture plugin, activates it via config, runs its contributed
command, and asserts the boot telemetry surface.
Add a confirmation gate to `hyp plugin install` and a real update flow
to `hyp plugin update <plugin>` so remote installs no longer commit
without the user (or `--yes`) seeing what is about to land.

Install (git sources)
- New `--yes`/`-y` flag.
- Non-TTY without `--yes` rejects with `remote_install_confirmation_required`
  (exit code 2).
- TTY without `--yes` prints a summary (source URL, resolved commit,
  plugin name + version, permissions, entrypoint, content_hash) and
  prompts `Proceed? [y/N]`. Both summary and prompt land on stderr so
  stdout stays parseable.
- Local-dir installs keep their current non-interactive behavior — the
  confirmation gate only fires inside `fetchGitSource`.

Update
- `hyp plugin update <plugin>` now re-clones with the same source, runs
  manifest validate, and shows old vs. new resolved_ref/version/content_hash
  before the artifact swap. `--yes` skips the prompt; non-TTY without
  `--yes` rejects as above. On rejection the prior install is left
  intact because the rename swap only fires once `beforeCommit` returns
  `proceed=true`.
- The bare `hyp plugin update` (no plugin name) keeps the legacy
  "refresh update_check state for every plugin" behavior so `outdated`
  can still be recomputed without committing to a re-install.

Soft warnings (stderr only)
- Manifest permissions containing `network` warn about broad scope.
- Branch-shaped refs (and missing refs) warn that pinning a tag or
  commit SHA gives reproducible installs.

Telemetry
- `plugin.install` and a new `plugin.update` span both carry a
  `confirmation` attribute drawn from `confirmed`, `auto_yes`,
  `rejected`, `non_tty_no_yes`. The `plugin.installed` log row mirrors
  the attribute when it was set.
- `fetch.FetchErrorKind` / `git_fetch.GitFetchErrorKind` gain
  `remote_install_confirmation_required` and `remote_install_rejected`.

Tests + smoke
- New `test/core/plugin-install-confirm.test.js` covers the pure
  helpers (`decideConfirmation`, `buildWarnings`, `sourceIsUnpinnedBranch`,
  `renderConfirmationSummary`) and exercises the `installPlugin` +
  `updatePlugin` integration against a hermetic file:// bare repo.
- New env-gated acceptance smoke
  `hypaware-core/smoke/flows/plugin_install_github_url.js` installs a
  real GitHub plugin fixture at a pinned tag (opt-in via
  `HYP_SMOKE_REAL_GITHUB=1`, with overridable URL/ref/name env vars).
  Skips with a clear marker when the env var is unset so CI runs of
  `npm run smoke` do not hit the network.
- The existing hermetic git-url and boot-installed smokes now pass
  `--yes` (they run with non-TTY buffer streams) and assert
  `confirmation=auto_yes` on the install span.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@philcunliffe
Copy link
Copy Markdown
Contributor Author

Dual-agent review — request_changes

  • Verdict: request_changes
  • Risk class: high
  • Blast-radius bead: hy-exum

Risk capstone

Cross-reference: reviewer findings that intersect high-risk surfaces

Source Finding (severity, evidence) Intersects
codex.md Fix Validations / incomplete: readline interactive prompt path uses node:readline callback API as if promise-based (await rl.question(...)), so interactive confirmations can throw before returning a decision. Evidence: src/core/plugin_install/confirm.js:167, src/core/plugin_install/confirm.js:171, src/core/plugin_install/install.js:148. Targets (confirm.js new, install.js modified); Concurrency surface §7 (confirm.js:168-179 readline buildTtyPrompt/ask — cited lines 167, 171 sit inside the same lifecycle)
codex.md Behavioral Correctness / major / high: git ls-remote parsing takes the first SHA, which is wrong for annotated tags (refs/tags/x + refs/tags/x^{}), causing false "update available" against resolved_ref. Evidence: src/core/plugin_install/update_check.js:121, src/core/plugin_install/update_check.js:137, src/core/plugin_install/update_check.js:154. Targets (update_check.js modified — +94 lines adding runGitProbe, execGit, parseLsRemoteOutput); same file as Concurrency surface §1 (update_check.js:180-202 execGit) — cited lines are in the new parseLsRemoteOutput helper that feeds the same probe path
codex.md Security Surface / major / high: credential-bearing URLs can be persisted and echoed via source.raw (lock/confirmation), despite stderr redaction in git-error paths. Evidence: src/core/plugin_install/resolver.js:56, src/core/plugin_install/install.js:195, src/core/plugin_install/confirm.js:98, src/core/plugin_install/git_fetch.js:635. Targets (resolver.js / install.js / confirm.js / git_fetch.js all in PR diff); Direct callers (resolver.js:53-69 parseGitSource caller block contains the cited resolver.js:56); Concurrency surface §1 (git_fetch.js:616-641 execGit decl contains the cited git_fetch.js:635)
claude.md issue 1: git clone, git checkout, and git ls-remote pass user-controlled gitUrl / ref / target as positional args without a -- end-of-options separator (CVE-2018-17456 family); neither CLI parser nor parseGitSource/applyGitSourceFlags rejects leading -. Evidence: src/core/plugin_install/git_fetch.js:238-244, src/core/plugin_install/git_fetch.js:283-289, src/core/plugin_install/update_check.js:130-140. Targets (git_fetch.js new, update_check.js modified); Concurrency surface §1 — every cited site is an execGit caller named in the surface map (runGitCloneSpan, runGitCheckoutSpan in git_fetch.js; runGitProbe in update_check.js)
claude.md issue 2: post-install probe hangs indefinitely — after a successful git install/update, checkForPluginUpdaterunGitProbeexecGit(['ls-remote', ...]) resolves only on child 'close' with no timeout; a slow or blackholed remote hangs hyp plugin install after the artifact and lock are already written. Evidence: src/core/plugin_install/update_check.js:100-145. Targets (update_check.js modified); Concurrency surface §1 (execGit "no timeout / no AbortSignal — a hung 'git ls-remote' never resolves"); Risks #2 — this finding is the exact failure mode the blast-radius report flags as MEDIUM ("execGit has no timeout... a hung git ls-remote (probe path) never resolves")
claude.md issue 3: applyGitSourceFlags JSDoc says the subdir value is "still recorded on the returned spec so the eventual lock entry shape stays forward compatible", but the function throws git_subdir_unsupported unconditionally when opts.subdir !== undefined, so the trailing return { ...parts, ref, ...(subdir !== undefined ? { subdir } : {}) } is unreachable when subdir is set. Evidence: src/core/plugin_install/git_source.js:97-137. Targets (git_source.js new — applyGitSourceFlags is the cited symbol); Direct callers (resolver.js:53-69 invokes parseGitSource/applyGitSourceFlags); Risks #6 — blast-radius report flags exactly this surface ("subdir is parsed but rejected... applyGitSourceFlags throws git_subdir_unsupported", a reserved-slot surface masquerading as a working flag)

Blast radius

  • Lock-file RMW has no serializability and the install path has a
    multi-second double-write window.
    installPlugin writes the lock
    at line 220 (entry without update state), runs
    checkForPluginUpdate (which calls git ls-remote over the
    network — seconds to minutes), then writes again at line 238 using
    nextLock built from initialLock. Any concurrent hyp plugin install that lands between those writes is silently clobbered.
    Two concurrent installs of DIFFERENT plugins also lose one entry
    via plain last-writer-wins. No flock / lockfile / fcntl advisory
    lock anywhere on this path. Worth a follow-up bead to wrap the
    RMW in an in-process mutex + file lock, especially now that the
    fetch surface includes a network round-trip.
  • execGit has no timeout and is duplicated. A hung
    git ls-remote (probe path) or git clone (install path) never
    resolves; the caller awaits forever and the child PID is
    reparented to PID 1 on hard exit. The implementation lives in
    BOTH update_check.js:180-202 and git_fetch.js:616-641, so any
    hardening (AbortController, signal, kill-on-cleanup) must land
    in two places or the binary is only half-hardened. Probe path is
    rate-limited to 24h so practical exposure is bounded; install
    path runs on every install.
  • Daemon reload does NOT pick up newly installed plugins.
    daemon/runtime.js:reload() re-reads config and per-source
    reload() but does not re-call discoverInstalledPlugins or
    re-activate. The asymmetry is invisible: hyp status will show
    the newly installed plugin in the validation pass (because
    daemon/status.js re-discovers per-call), while the kernel's
    active-plugin set is frozen at boot. Operators following the
    documented install → reload ritual will get a misleading "all
    good" signal.
  • Installed-plugin discovery failures are silently swallowed in
    three CLI paths.
    buildKnownPluginsForCtx wraps
    discoverInstalledPlugins in try / catch and returns an empty
    merged map on failure. daemon/status.js does the same. The
    underlying plugin.installed_manifest_invalid log row carries the
    detail, but the operator running hyp config validate sees
    "everything passed" with no signal that their installed plugins
    weren't considered. Worth a non-fatal warning printed to stderr
    when discovery returns failed[] entries.
  • Shadow-collision is a hard boot fail with no graceful recovery.
    bootKernel throws installed_shadows_bundled if any installed
    plugin name matches a bundled one. The error message tells the
    operator to run hyp plugin remove, but the CLI can't reach the
    daemon if the daemon won't boot, and there is no "skip installed
    shadows and continue" flag. A user who installed
    @hypaware/local-fs from a fork and then ran hyp init ends up
    with a non-bootable kernel until they manually edit the lock file
    or rm -rf the install dir. Consistent with the Phase 7 design
    intent (hy-gh-2), but worth surfacing more loudly in the error
    message and the smoke surface.
  • subdir is parsed but rejected — a "reserved" surface masquerading
    as a working flag.
    The --path CLI flag, the subdir resolver
    option, and the PluginSourceSpec.subdir typed field all exist and
    flow through the pipeline, but every code path that observes a
    non-empty subdir throws git_subdir_unsupported. Users hitting
    the CLI flag will get an error AFTER the clone has already happened.
    The clone is cheap, but the failure mode is "the path was reserved,
    it does not yet do anything" rather than "this flag is not yet
    implemented" — a --path flag that fast-fails in
    parsePluginInstallArgs would be a less surprising UX.
  • Exit-code collision between usage error and confirmation
    required.
    runPluginInstall returns 2 for argv parse errors
    AND for remote_install_confirmation_required. CI scripts that
    treat exit 2 as "user typo, retry never" will misclassify a TTY
    detection failure (e.g., a CI runner with an unexpected stdin)
    as a permanent error. The stderr message distinguishes them; the
    exit code does not. Easy to split into 3 for confirm-required
    if scripts mature past hand-grep.
Codex review

Fix Validations

Remote install/update confirmation gate

Installed plugins included in config validation metadata

Boot-time installed-vs-bundled shadow rejection

Findings

[Behavioral Correctness]

[Security Surface]

No Finding

    1. Contract & Interface Fidelity
    1. Change Impact / Blast Radius
    1. Concurrency, Ordering & State Safety
    1. Error Handling & Resilience
    1. Resource Lifecycle & Cleanup
    1. Release Safety
    1. Test Evidence Quality
    1. Architectural Consistency
    1. Debuggability & Operability

Evidence Bundle

Claude review

Code review

Note: This PR is a feature-flow integration branch (intentionally draft until ship). The code-review-quiet skill was invoked by mol-pr-dual-review with --no-comment --output, so the draft gate is overridden in this orchestrator context.

Found 3 issues:

  1. Git option-injection: git clone, git checkout, and git ls-remote pass user-controlled gitUrl and ref/target as positional args without a -- end-of-options separator, and neither the CLI parser nor parseGitSource/applyGitSourceFlags rejects leading - in those values. A ref like --upload-pack=<cmd> (or a URL like --config=...) would be interpreted by git as an option rather than a value (CVE-2018-17456 family). Fix: insert -- before user-controlled positionals in each execGit call AND reject inputs matching ^- in the resolver / CLI parser.

git_repo: provenance.repo ?? '',
},
async (span) => {
const cloned = await execGit(['clone', '--filter=blob:none', '--no-checkout', gitUrl, tmpRepo])
if (cloned.code !== 0) {
span.setAttribute('status', 'failed')
span.setAttribute('error_kind', 'git_clone_failed')

message: 'plugin install: could not determine default branch on cloned repo',
})
}
const checked = await execGit(['-C', tmpRepo, 'checkout', target])
if (checked.code !== 0) {
const isRefError = /pathspec|did not match|unknown revision|not a tree/i.test(checked.stderr)
const errorKind = isRefError ? 'git_ref_not_found' : 'git_checkout_failed'

if (!gitUrl) {
return { checked_at: checkedAt, available: false, error: 'missing gitUrl' }
}
const ref = entry.source.ref || 'HEAD'
const probe = await execGit(['ls-remote', '--quiet', gitUrl, ref])
if (probe.code !== 0) {
return {
checked_at: checkedAt,
available: false,
error: 'git_ls_remote_failed',
}

  1. Post-install probe can hang indefinitely: after a successful git install/update, the path awaits checkForPluginUpdaterunGitProbeexecGit(['ls-remote', '--quiet', gitUrl, ref]) inside the plugin.install span, and execGit resolves only on the child's close event with no timeout, so a slow or blackholed remote will hang hyp plugin install after the artifact and lock are already written. Fix: either skip checkForPluginUpdate for fresh git installs (the just-resolved resolved_ref is by definition current) or wrap the git ls-remote spawn with a hard timeout that emits a structured git_probe_timeout error_kind.

*/
async function runProbe(entry, now) {
const checkedAt = now.toISOString()
if (entry.source.kind === 'local-dir') {
return { checked_at: checkedAt, available: false }
}
if (entry.source.kind === 'git') {
return runGitProbe(entry, checkedAt)
}
return {
checked_at: checkedAt,
available: false,
}
}
/**
* Probe a git source's upstream by running `git ls-remote`. The probe
* compares the resolved upstream commit for the locked `source.ref`
* (or the remote's default `HEAD`) against the entry's `resolved_ref`
* and sets `available=true` when they differ.
*
* The probe is best-effort: a non-zero exit or unparseable output
* records an error on the returned state but never throws.
*
* @param {import('../../../collectivus-plugin-kernel-types').PluginLockEntry} entry
* @param {string} checkedAt
* @returns {Promise<import('../../../collectivus-plugin-kernel-types').PluginUpdateState>}
*/
async function runGitProbe(entry, checkedAt) {
const gitUrl = entry.source.gitUrl
if (!gitUrl) {
return { checked_at: checkedAt, available: false, error: 'missing gitUrl' }
}
const ref = entry.source.ref || 'HEAD'
const probe = await execGit(['ls-remote', '--quiet', gitUrl, ref])
if (probe.code !== 0) {
return {
checked_at: checkedAt,
available: false,
error: 'git_ls_remote_failed',
}
}
const latestRef = parseLsRemoteOutput(probe.stdout)
if (!latestRef) {
return {
checked_at: checkedAt,

  1. applyGitSourceFlags JSDoc contradicts behavior: the docblock says the subdir value is "still recorded on the returned spec so the eventual lock entry shape stays forward compatible", but the function throws git_subdir_unsupported unconditionally when opts.subdir !== undefined, so the trailing return { ...parts, ref, ...(subdir !== undefined ? { subdir } : {}) } is unreachable when subdir is set and the documented forward-compatibility promise cannot be delivered. Fix: drop the "still recorded" sentence from the JSDoc, OR record the spec before throwing.

/**
* Apply `--ref` / `--path` (subdir) CLI flags on top of a base
* `GitSourceParts`. Enforces the two design-mandated rules:
*
* - Providing `--ref` AND a URL `#<ref>` fragment is ambiguous → throw
* `source_ambiguous`.
* - Providing `--path <subdir>` is unsupported in MVP → throw
* `git_subdir_unsupported`, with the value still recorded on the
* returned spec so the eventual lock entry shape stays forward
* compatible.
*
* The caller decides how to wire the throw into telemetry; the kernel
* funnels it through `resolveSource()`'s `resolver_error` path.
*
* @param {GitSourceParts} parts
* @param {{ ref?: string, subdir?: string }} [opts]
* @returns {GitSourceParts & { subdir?: string }}
*/
export function applyGitSourceFlags(parts, opts = {}) {
let ref = parts.ref
if (opts.ref) {
if (parts.ref) {
throw newGitSourceError(
'source_ambiguous',
`plugin install: --ref '${opts.ref}' conflicts with URL fragment '#${parts.ref}'`
)
}
ref = opts.ref
}
const subdir = opts.subdir
if (subdir !== undefined) {
throw newGitSourceError(
'git_subdir_unsupported',
`plugin install: --path '${subdir}' is reserved but not yet supported`
)
}
return { ...parts, ref, ...(subdir !== undefined ? { subdir } : {}) }
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.


Reports: /Users/phil/testcity/.gc/pr-pipeline/reviews/pr-6 · Bead: hy-z8kt · Blast-radius: hy-exum

…-lpo3) (#18)

Address all blocker and major findings from the dual-review on PR #6:

confirm.js — switch to node:readline/promises so the awaited
rl.question() actually returns a Promise and the interactive TTY
prompt path can return a decision instead of throwing.

update_check.js — pickLsRemoteSha now prefers the peeled `^{}` SHA
when present so annotated tags compare against entry.resolved_ref
correctly instead of producing spurious "update available" signals.
Also wraps the ls-remote spawn with a hard timeout (default 5s,
HYP_GIT_PROBE_TIMEOUT_MS-overridable, clamped) that emits a
git_probe_timeout error_kind, and the post-install path now passes
freshlyResolved=true so the probe is skipped entirely when we just
resolved the upstream ref — the span still emits with
probe_skipped=freshly_resolved for observability.

git_source.js / resolver.js / install.js — strip user:pass@ userinfo
from source.raw and source.gitUrl before they reach the lock entry,
the confirmation prompt, or the install span. Persistence and display
are always credential-free; clones rely on the user's git credential
helper. Adds redactGitUrl / redactRawSource helpers.

git_fetch.js / update_check.js — insert `--` end-of-options separator
before user-controlled positionals in `git clone`, `git checkout`,
and `git ls-remote` so a hostile URL or ref like `--upload-pack=<cmd>`
cannot be parsed as a git option (CVE-2018-17456 family). Also reject
leading-dash inputs in parseGitSource, applyGitSourceFlags, and the
CLI parser as belt-and-braces defense before the spawn boundary.

git_source.js — applyGitSourceFlags JSDoc no longer claims the subdir
value is "still recorded on the returned spec" since the function
throws unconditionally; the unreachable subdir spread is removed.

Tests cover redactGitUrl/redactRawSource, leading-dash rejection
across parser layers, peeled-tag SHA preference in pickLsRemoteSha,
and the real readline/promises behavior of buildTtyPrompt via
PassThrough streams.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@philcunliffe philcunliffe marked this pull request as ready for review May 22, 2026 01:31
@philcunliffe philcunliffe merged commit cca7180 into master May 22, 2026
6 checks passed
@philcunliffe philcunliffe mentioned this pull request May 23, 2026
@platypii platypii deleted the integration/github-install branch May 28, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant