Skip to content

feat: optional OneCLI/MITM proxy credential routing - #17

Merged
jbaruch merged 12 commits into
mainfrom
feat/onecli-proxy-credential-routing
Jul 10, 2026
Merged

feat: optional OneCLI/MITM proxy credential routing#17
jbaruch merged 12 commits into
mainfrom
feat/onecli-proxy-credential-routing

Conversation

@jbaruch

@jbaruch jbaruch commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add opt-in OneCLI / MITM credential-gateway mode gated on ONECLI_URL (set by onecli run)
  • When enabled: undici ProxyAgent for global fetch, TripIt iCal via proxied fetch, Google Calendar static Bearer with no OAuth self-refresh, placeholder TripIt/Reclaim credentials accepted for gateway injection
  • When ONECLI_URL is unset: behavior is unchanged; existing tests pass as before

Closes #16.

Test plan

  • npm test — 182 pass (proxy routing, placeholder acceptance, Google no-refresh, non-OneCLI regression)
  • With ONECLI_URL unset: dry-run/sync behave as today
  • Under onecli run: TripIt + Reclaim requests hit the gateway; placeholders swap correctly
  • Under OneCLI with Google Calendar connection configured + ENABLE_OOO=1: OOO works without local OAuth refresh
  • Under OneCLI without Google connection: 401 surfaces actionable error (or ENABLE_OOO=0 skips OOO)

Gate proxy + placeholder-credential behavior on ONECLI_URL so non-OneCLI
runs stay byte-for-byte identical. When set (via `onecli run`):

- install undici ProxyAgent for global fetch (Reclaim, TripIt, Telegram)
- fetch TripIt iCal via proxied fetch instead of node-ical fromURL
- Google Calendar uses a static Bearer with no OAuth self-refresh
- accept placeholder TripIt/Reclaim credentials for gateway injection
- OOO opt-in via ENABLE_OOO or GOOGLE_* placeholder presence

Adds undici, lib/proxy.mjs, tests for proxy routing / Google bypass /
placeholder acceptance, and README/.env.example docs.
Copilot AI review requested due to automatic review settings July 10, 2026 21:53
@jbaruch

jbaruch commented Jul 10, 2026

Copy link
Copy Markdown
Owner Author

@copilot review this pull request

@jbaruch jbaruch left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Requesting Copilot review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds an opt-in OneCLI / MITM credential-gateway mode (gated by ONECLI_URL) so outbound HTTP can be routed through a proxy for credential injection, while keeping the non-OneCLI path unchanged.

Changes:

  • Installs an undici ProxyAgent dispatcher (only in OneCLI mode) so global fetch respects HTTPS_PROXY, with optional CA plumbing via NODE_EXTRA_CA_CERTS.
  • Updates TripIt iCal fetching to use proxied global fetch under OneCLI; updates Google Calendar client creation to avoid OAuth refresh and use a static Bearer placeholder under OneCLI.
  • Adds unit tests and documentation for OneCLI mode and placeholder credential behavior.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
sync.mjs Early proxy dispatcher install; reuse a single Google Calendar client; OneCLI-specific error messaging around OOO sync.
README.md Documents OneCLI mode usage, proxy/CA behavior, placeholder credential injection, and OOO opt-in rules.
package.json Adds undici dependency for ProxyAgent / global dispatcher support.
package-lock.json Locks undici dependency and records its Node engine requirement.
lib/tripit.mjs Uses proxied global fetch + parseICS in OneCLI mode to avoid node-ical’s non-proxied URL fetching.
lib/proxy.mjs New module to detect OneCLI mode and install/reset the global undici proxy dispatcher (with optional CA loading).
lib/proxy.test.mjs New tests for OneCLI gating, fail-fast behavior, CA handling, and proxy routing for Reclaim + TripIt.
lib/google-calendar.mjs OneCLI-mode static Bearer auth path + new isOooOptedIn helper; preserves OAuth refresh path outside OneCLI.
lib/google-calendar.test.mjs Expands tests to cover OneCLI opt-in behavior and verify no OAuth token refresh network calls occur.
CHANGELOG.md Notes the new optional OneCLI credential-gateway mode and behavior changes when gated on ONECLI_URL.
.env.example Adds OneCLI-related env var documentation and OOO opt-in examples.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread package.json
Comment on lines 12 to 16
"geo-tz": "^8.1.1",
"googleapis": "^171.4.0",
"node-ical": "^0.20.1"
"node-ical": "^0.20.1",
"undici": "^8.7.0"
},

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7feebc9 — declared "engines": { "node": ">=22.19.0" } to match undici@8. Tried pinning undici 7 for broader Node 22, but its ProxyAgent hung the mock-proxy tests; staying on undici 8 + engines is the cleaner contract (Dockerfile already uses node:22-alpine).

Comment thread sync.mjs
Comment on lines +276 to +290
try {
oooStats = await syncOooEvents(client, gcal, futureTrips);
} catch (err) {
// Surface OneCLI Google-connection misconfig clearly (gateway 401
// when the built-in Google Calendar connection isn't authorized).
if (process.env.ONECLI_URL && /401|unauthorized|invalid.?credential/i.test(err.message)) {
throw new Error(
`Google Calendar OOO failed under OneCLI (${err.message}). ` +
'Configure and authorize the OneCLI Google Calendar connection ' +
'(`onecli apps configure`) so the gateway can inject a real Bearer, ' +
'or set ENABLE_OOO=0 to skip OOO.',
);
}
throw err;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 7feebc9 — safe msg extraction (err instanceof Error ? err.message : String(err)) and rethrow with { cause: err } so non-Error throws don't get swallowed as a TypeError on .message.

Copilot AI commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@copilot review this pull request

Review complete. The implementation is well-structured and the gate on ONECLI_URL keeps non-OneCLI paths byte-for-byte unchanged. All 182 tests pass.

One fix applied (commit fix: remove unused http import in google-calendar.test.mjs):

  • import http from 'node:http' in lib/google-calendar.test.mjs was unused — http is only needed in proxy.test.mjs for the local test servers.

Other observations (no action needed):

  • ONECLI_ACCESS_TOKEN exported from google-calendar.mjs — intentional for test assertions; the value is a non-secret placeholder string.
  • Top-level installProxyDispatcher() in sync.mjs — unusual pattern, but the comment clearly documents that it must be the first side-effect call. All imports are hoisted in ESM, so HTTP requests (which are async) happen after the proxy is installed; the setup is correct.
  • isOooOptedIn non-OneCLI branch duplicates the clientId && clientSecret && refreshToken check inside createGCalClient — this is acceptable; the function is a separate utility exported for dry-run messaging and sync.mjs callers.
  • Regex /401|unauthorized|invalid.?credential/i in the OneCLI error handler is intentionally broad to catch gateway-flavoured 401 messages across Google API client versions.
  • No new security vulnerabilities introduced. Existing npm audit findings are in pre-existing transitive dependencies (axios, brace-expansion) and are unrelated to this PR.

CodeQL scan also came back clean.

- Declare engines.node >=22.19.0 to match undici@8's requirement
  (node:22-alpine consumers need a recent 22.x; undici 7 hung proxy tests)
- Harden OneCLI OOO error wrapping: safe message extraction and Error cause

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Comment thread lib/proxy.test.mjs Outdated
Comment on lines +121 to +124
// Spins up a local HTTP "origin" server and a local HTTP CONNECT-capable
// proxy. With ONECLI_URL + HTTPS_PROXY pointing at the mock proxy, undici's
// ProxyAgent must forward the request — we assert the proxy saw it and
// the origin saw the expected placeholder credentials.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c3d94d2 — corrected the suite header and split the suites: HTTP forward (absolute-form) vs HTTPS CONNECT. The CONNECT suite now stands up an https:// origin with an ephemeral self-signed cert (SAN=IP:127.0.0.1), a CONNECT-tunnel mock proxy, and asserts both the CONNECT target and the placeholder Bearer on the origin.

Comment thread lib/proxy.test.mjs Outdated
Comment on lines +171 to +174
// Minimal HTTP forward proxy: rewrites absolute-form request-target
// to origin-form and relays to the real origin. Enough for undici
// ProxyAgent's non-CONNECT (http://) path.
proxyServer = http.createServer((req, res) => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in c3d94d2 — added proxy routing under OneCLI mode (HTTPS CONNECT) covering Reclaim + TripIt over https:// via CONNECT, with the self-signed origin cert trusted through NODE_EXTRA_CA_CERTS / requestTls.ca. Production-relevant path is now exercised; 184 tests green.

Address Copilot re-review: the mock proxy was documented as CONNECT-capable
but only handled HTTP absolute-form. Split HTTP-forward vs HTTPS-CONNECT
suites and assert CONNECT tunnels for https:// Reclaim/TripIt origins with
an ephemeral self-signed cert trusted via NODE_EXTRA_CA_CERTS.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Comment thread lib/proxy.test.mjs Outdated
Comment on lines +294 to +302
execFileSync('openssl', [
'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', keyPath, '-out', certPath,
'-days', '1', '-nodes',
'-subj', '/CN=127.0.0.1',
'-addext', 'subjectAltName=IP:127.0.0.1,DNS:localhost',
], { stdio: 'pipe' });
const key = readFileSync(keyPath);
const cert = readFileSync(certPath);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 13179df — openssl spawn failures (ENOENT / missing binary) now throw an actionable message that the HTTPS CONNECT suite needs openssl on PATH; other openssl failures include stderr detail.

Comment thread sync.mjs Outdated
Comment on lines +1 to +5
// Install undici ProxyAgent before any HTTP when running under OneCLI
// (`ONECLI_URL` set by `onecli run`). No-op otherwise — keeps non-OneCLI
// behavior byte-for-byte identical. Must be the first side-effect import.
import { installProxyDispatcher } from './lib/proxy.mjs';
installProxyDispatcher();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 13179df — rewrote the comment to state the ESM truth: all static imports evaluate before the module body, so install is the explicit installProxyDispatcher() call (safe only because lib/* do no HTTP at load time).

Address Copilot review on c3d94d2:
- Document that ESM evaluates all static imports before the module body,
  so proxy install is the explicit call (not import order)
- Surface a clear error when openssl is missing for HTTPS CONNECT tests

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Comment thread lib/google-calendar.mjs
Comment on lines +60 to +63
/**
* Whether OOO / Google Calendar is opted in under the current env.
* Exported for sync.mjs dry-run messaging.
*/

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d42b596 — JSDoc now says used by createGCalClient and sync skip messaging (no false claim about dry-run importing it).

Comment thread sync.mjs
Comment on lines 276 to 278
if (!gcal) {
console.log('\n OOO blocks: skipped (Google Calendar credentials not configured)');
} else {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d42b596 — sync and dry-run both use oooSkipReason() so OneCLI operators see ENABLE_OOO / placeholder guidance instead of "credentials not configured".

Comment thread sync.mjs
Comment on lines +218 to 221
if (futureTrips.length > 0 && gcal) {
console.log(`\n── OOO blocks (would create) ──`);
for (const t of futureTrips) {
console.log(` ${OOO_PREFIX}${t.summary} ${t.startDate} → ${t.endDate}`);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in d42b596 — same as the sync path; dry-run now uses oooSkipReason(googleCreds).

Copilot: dry-run/sync said "credentials not configured" even when
ONECLI_URL is set and OOO simply wasn't opted in. Add oooSkipReason()
and use it for both skip paths; fix isOooOptedIn JSDoc.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Comment thread sync.mjs Outdated
Comment on lines +1 to +9
// OneCLI mode: install undici ProxyAgent before any HTTP.
// ESM evaluates all static imports (the whole dependency graph) before this
// module body runs, so import order does NOT control when the agent is
// installed — the explicit call below does. That is safe because lib/*
// only export functions; they do not perform HTTP at load time. Any future
// top-level HTTP in an imported module would race this and break OneCLI.
// No-op when ONECLI_URL is unset (byte-for-byte identical non-OneCLI path).
import { installProxyDispatcher } from './lib/proxy.mjs';
installProxyDispatcher();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fba32c1 — reworded: no-op is about HTTP routing (no ProxyAgent / dispatcher swap), not zero module-graph difference; undici may still load via the import.

Comment thread lib/proxy.test.mjs Outdated
Comment on lines +294 to +306
try {
execFileSync('openssl', [
'req', '-x509', '-newkey', 'rsa:2048',
'-keyout', keyPath, '-out', certPath,
'-days', '1', '-nodes',
'-subj', '/CN=127.0.0.1',
'-addext', 'subjectAltName=IP:127.0.0.1,DNS:localhost',
], { stdio: 'pipe' });
} catch (err) {
const detail = err.stderr?.toString?.() || err.message || String(err);
const missing = err.code === 'ENOENT'
|| /not found|No such file|spawn openssl/i.test(detail);
throw new Error(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in fba32c1 — preflight openssl version; suite uses describe.skip when openssl is missing so minimal environments skip cleanly instead of hard-failing.

- Skip HTTPS CONNECT suite when openssl is not on PATH (describe.skip)
- Clarify that OneCLI off means no dispatcher swap, not zero module-graph change

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.

Comment thread sync.mjs
Comment on lines +292 to +303
const msg = err instanceof Error ? err.message : String(err);
if (process.env.ONECLI_URL && /401|unauthorized|invalid.?credential/i.test(msg)) {
throw new Error(
`Google Calendar OOO failed under OneCLI (${msg}). ` +
'Configure and authorize the OneCLI Google Calendar connection ' +
'(`onecli apps configure`) so the gateway can inject a real Bearer, ' +
'or set ENABLE_OOO=0 to skip OOO.',
{ cause: err },
);
}
throw err;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 9cef7da — non-Error throws are wrapped in new Error(msg, { cause }) before rethrow; top-level fatal path also uses safe message extraction.

Comment thread lib/google-calendar.mjs Outdated
Comment on lines +66 to +68
const flag = process.env.ENABLE_OOO;
if (flag === '0' || flag === 'false') return false;
if (flag === '1' || flag === 'true') return true;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 9cef7da — shared enableOooFlag() lowercases ENABLE_OOO; TRUE/FALSE/yes/no/on/off work.

Comment thread lib/google-calendar.mjs
Comment on lines +83 to +90
if (process.env.ONECLI_URL) {
const flag = process.env.ENABLE_OOO;
if (flag === '0' || flag === 'false') {
return 'OOO disabled under OneCLI (ENABLE_OOO=0)';
}
return 'OOO not opted in under OneCLI (set ENABLE_OOO=1, or set GOOGLE_* placeholders as opt-in markers)';
}
return 'Google Calendar credentials not configured';

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 9cef7da — oooSkipReason uses the same case-insensitive enableOooFlag().

- ENABLE_OOO accepts TRUE/FALSE/etc. case-insensitively in opt-in + skip reason
- OOO catch and top-level fatal path always surface a string message

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comment thread lib/proxy.mjs
Comment on lines +49 to +54
const caPath = process.env.NODE_EXTRA_CA_CERTS;
if (caPath) {
try {
const ca = readFileSync(caPath);
agentOpts.requestTls = { ca, rejectUnauthorized: true };
agentOpts.proxyTls = { ca, rejectUnauthorized: true };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in b19be30ca: [...tls.rootCertificates, extraCa] so we append like NODE_EXTRA_CA_CERTS, not replace the default trust store.

NODE_EXTRA_CA_CERTS appends at Node startup; undici requestTls.ca replaces.
Append extra CA to tls.rootCertificates so public roots stay available.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comment thread sync.mjs Outdated
Comment on lines +11 to +12
import { installProxyDispatcher } from './lib/proxy.mjs';
installProxyDispatcher();

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 3871080installProxyDispatcher() now runs inside the main try after CLI/output parsing, so missing HTTPS_PROXY flows through the same fatal path (including --output=json). Verified smoke: errors array contains the actionable message.

Move installProxyDispatcher() after CLI/output setup into the main try
so ONECLI_URL without HTTPS_PROXY exits via --output=json shaping.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comment thread lib/proxy.test.mjs
Comment on lines +98 to +109
it('reads NODE_EXTRA_CA_CERTS into requestTls when the file exists', () => {
const dir = mkdtempSync(join(tmpdir(), 'onecli-ca-'));
const caPath = join(dir, 'ca.pem');
// Minimal PEM-looking blob — ProxyAgent only needs to read the bytes
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n');
process.env.ONECLI_URL = 'http://127.0.0.1:9999';
process.env.HTTPS_PROXY = 'http://127.0.0.1:8888';
process.env.NODE_EXTRA_CA_CERTS = caPath;
const agent = installProxyDispatcher();
assert.ok(agent instanceof ProxyAgent);
unlinkSync(caPath);
});

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in 8f0714c — temp CA dir is rmSync’d in a finally block (whole dir, not just the PEM).

rmSync the whole mkdtemp dir in finally so /tmp is not left littered.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.

Comment thread lib/proxy.test.mjs Outdated
Comment on lines +222 to +225
it('routes Reclaim listEntries through the proxy with a placeholder Bearer', async () => {
// listEntries hardcodes api.app.reclaim.ai; we exercise the same client
// + dispatcher path with a fetch that targets the mock origin.
const client = createClient('PLACEHOLDER-RECLAIM-TOKEN');

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in b4dc65c — renamed to “Reclaim-style global fetch”; comment explains why we cannot call listEntries directly without a base-URL seam.

Comment thread lib/proxy.mjs
Comment on lines +30 to +32
* @returns {ProxyAgent|null} the installed agent, or null when not in OneCLI mode
* @throws {Error} when ONECLI_URL is set but HTTPS_PROXY is missing
*/

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed in b4dc65c@throws now also covers NODE_EXTRA_CA_CERTS missing/unreadable.

- Rename test to say global fetch, not listEntries (no base-URL seam)
- Document NODE_EXTRA_CA_CERTS read failures in installProxyDispatcher @throws

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.

@jbaruch
jbaruch merged commit dc582da into main Jul 10, 2026
1 check passed
@jbaruch
jbaruch deleted the feat/onecli-proxy-credential-routing branch July 10, 2026 23:04
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.

feat: optional OneCLI/MITM-proxy credential routing, gated on ONECLI_URL

3 participants