Skip to content

feat: add hog harness, a pi distribution#3219

Merged
jonathanlab merged 5 commits into
mainfrom
feat/harness
Jul 7, 2026
Merged

feat: add hog harness, a pi distribution#3219
jonathanlab merged 5 commits into
mainfrom
feat/harness

Conversation

@jonathanlab

@jonathanlab jonathanlab commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Problem

We need a debuggable, stable, and modifiable harness.

Changes

  • Added a new package called harness, using Pi.dev under the hood. This harness can be launched using either the SDK or the CLI.
  • Add a bunch of extensions, some more WIP than others: subagent, web-access, posthog-provider, and hog-branding. I've used this community extension for MCP support: https://github.com/nicobailon/pi-mcp-adapter

@trunk-io

trunk-io Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging to main in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 0fa4044.

@greptile-apps

greptile-apps Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Security Review

  • SSRF via private IPs (web-fetch.ts): validateUrl blocks localhost by segment count but passes all numeric IP literals (127.0.0.1, 192.168.x.x, 10.x.x.x, 169.254.169.254, etc.). An adversarially injected prompt could direct the LLM to fetch the AWS metadata endpoint or any internal service over the agent's network.
  • Unbounded response body (web-fetch.ts): The content-length guard falls through to 0 when the header is absent (chunked encoding), so response.text() reads the entire body with no ceiling.
  • No hardcoded secrets, credential leakage through logs, or SQL/XSS injection vectors identified. The OAuth PKCE implementation, state validation, and auth-bridge key-material handling (mode: 0o600) appear sound.

Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/harness" | Re-trigger Greptile

Comment on lines +42 to +60

if (parsed.username || parsed.password) {
return {
valid: false,
reason: "URLs with embedded credentials are not supported",
};
}

const parts = parsed.hostname.split(".");
if (parts.length < 2) {
return { valid: false, reason: "URL must have a public hostname" };
}

return { valid: true, url: parsed };
}

interface CacheEntry {
bytes: number;
code: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security SSRF via private/loopback IP addresses

validateUrl only checks that the hostname has 2 or more dot-separated segments, which blocks localhost but passes every numeric IP (127.0.0.1, 0.0.0.0, 192.168.x.x, 10.x.x.x, 172.16-31.x.x, 169.254.169.254). A prompt-injected instruction like web_fetch("http://169.254.169.254/latest/meta-data/iam/security-credentials/", ...) would pass validation and reach the AWS metadata endpoint (or any other internal service) from within the agent's network. None of the existing validateUrl tests cover IP literals, confirming the gap.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0fa4044: validateUrl now rejects loopback/private/link-local/CGNAT IPv4 and IPv6 literal hostnames (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 incl. cloud metadata, 100.64.0.0/10, 198.18.0.0/15, ::1, fe80::/10, fc00::/7, IPv4-mapped IPv6). Added test coverage including the exact 169.254.169.254 case. Noted in a code comment that this is a literal-address check only — it doesn't protect against DNS rebinding (a public hostname whose DNS record later resolves to a private address), which would need to be enforced at connection time.

Comment on lines +248 to +267
cached.content,
params.prompt,
gateway,
signal,
);
return {
content: [{ type: "text" as const, text: summary }],
details,
};
}

return returnMarkdown(cached.content, details);
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Content-length header absence bypasses the body-size guard

When a server omits content-length (typical for chunked-encoded or streaming responses), contentLength falls through to 0, making 0 > MAX_CONTENT_LENGTH false and skipping the guard entirely. The subsequent response.text() call then reads the entire body with no ceiling. A server that responds with chunked transfer encoding and streams indefinitely would exhaust process memory.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0fa4044: added readBodyWithLimit(), which streams the response body via response.body.getReader() and enforces MAX_CONTENT_LENGTH as bytes actually arrive, cancelling the reader and throwing once the cap is exceeded — regardless of whether/what the content-length header says. Kept the header-based check as a fast-fail before reading anything when the server is honest about a huge size, but the streaming cap is now the real backstop. Added tests for both an oversized chunked/no-content-length response (rejected) and a small chunked response with no content-length (still works).

Comment on lines +128 to +136
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(SUCCESS_PAGE);
cleanup();
if (error) {
reject(new Error(`PostHog OAuth error: ${error}`));
} else if (!code) {
reject(new Error("PostHog OAuth callback missing code"));
} else if (expectedState && state !== expectedState) {
reject(new Error("PostHog OAuth state mismatch"));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The success page is sent to the browser regardless of whether the code/state validation will pass. If there is a state mismatch or an OAuth error parameter, the user's browser shows "Authentication complete" while the CLI is about to reject the login. Consider serving a different page on the error path.

Suggested change
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(SUCCESS_PAGE);
cleanup();
if (error) {
reject(new Error(`PostHog OAuth error: ${error}`));
} else if (!code) {
reject(new Error("PostHog OAuth callback missing code"));
} else if (expectedState && state !== expectedState) {
reject(new Error("PostHog OAuth state mismatch"));
const hasError =
!!error || !code || (expectedState && state !== expectedState);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(hasError ? SUCCESS_PAGE.replace("Authentication complete", "Authentication failed — please return to your terminal") : SUCCESS_PAGE);
cleanup();
if (error) {
reject(new Error(`PostHog OAuth error: ${error}`));
} else if (!code) {
reject(new Error("PostHog OAuth callback missing code"));
} else if (expectedState && state !== expectedState) {
reject(new Error("PostHog OAuth state mismatch"));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0fa4044, thanks — took your suggestion's approach: the callback handler now determines success/failure first, then serves a distinct failure page ("Authentication failed — please return to your terminal") on error/missing-code/state-mismatch instead of always serving the success page. Added test coverage asserting the served page body for the success path and all three failure paths.

Comment on lines +150 to +162
/**
* Writes the run's transcript, capped so a very long-running or verbose
* subagent can't grow an unbounded file on disk.
*/
export function writeTranscript(
runId: string,
markdown: string,
maxBytes: number = DEFAULT_MAX_TRANSCRIPT_BYTES,
): void {
const filePath = transcriptPath(runId);
fs.mkdirSync(path.dirname(filePath), { recursive: true });

const byteLength = Buffer.byteLength(markdown, "utf-8");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 O(n) character-by-character trim loop for multibyte text

When the transcript contains multibyte characters near the truncation point, the while (Buffer.byteLength(truncated) > maxBytes) truncated = truncated.slice(0, -1) loop removes one character at a time and re-computes Buffer.byteLength over the entire remaining string on each iteration. A binary-search approach or a TextEncoder-based forward scan would be significantly more efficient.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0fa4044. Style matches our conventions — extracted a shared text-truncate.ts with a single Buffer.from(text, "utf8") encode + at-most-3-byte backoff to the nearest UTF-8 codepoint boundary, used by both lifecycle.ts's writeTranscript and format.ts's truncateForModel (which had the identical O(n) pattern). Added dedicated tests including multi-byte (2-byte é, 4-byte emoji, 3-byte €) boundary cases to confirm no split codepoints/replacement characters.

…or page, transcript truncation perf)

- web-fetch: reject loopback/private/link-local IPv4 and IPv6 literal
  hostnames in validateUrl (e.g. 169.254.169.254 cloud metadata,
  10.x/172.16-31.x/192.168.x, ::1, fc00::/7, IPv4-mapped IPv6). Literal-address
  check only; does not protect against DNS rebinding.
- web-fetch: enforce MAX_CONTENT_LENGTH while streaming the response body
  (readBodyWithLimit), not just via the content-length header, which is
  absent for chunked/streamed responses and let a server exhaust memory.
- oauth: don't serve the OAuth success page before validating the callback;
  serve a distinct failure page on error/missing-code/state-mismatch so the
  browser doesn't say "Authentication complete" right before the CLI login
  rejects.
- subagent: replace the O(n) per-iteration byte-length truncation loop in
  lifecycle.ts/format.ts with a single-pass UTF-8-boundary-aware truncation
  (text-truncate.ts), shared by both call sites.

Addresses review comments from #3219.
@jonathanlab jonathanlab merged commit e8e5ed5 into main Jul 7, 2026
24 checks passed
@jonathanlab jonathanlab deleted the feat/harness branch July 7, 2026 11: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.

2 participants