Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,10 @@ jobs:
- name: Postgres-backed tests (durability + cross-process)
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/qm
run: npm run test:pg
run: |
npm run test:pg
npm ci --prefix integrations/inwise
npm test --prefix integrations/inwise

admin-plugin:
name: Admin plugin
Expand Down
12 changes: 12 additions & 0 deletions adrs/inwise-oss-meeting-layer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Inwise OSS meeting layer for QM

We are providing Inwise OSS as a local meeting-memory layer for QM. It lets a personal QM agent search meetings, transcripts, people, upcoming meetings, and action items without requiring Inwise Cloud.

This PR includes the adapter under [`integrations/inwise`](../integrations/inwise/README.md):

- a read-only `inwise` CLI and QM [`tool.json`](../integrations/inwise/qm/tool.json)
- a QM meeting-memory [`SKILL.md`](../integrations/inwise/skill/SKILL.md)
- an outbound laptop connector and encrypted self-hosted relay
- a reproducible QM deployment fixture and [passing bridge test](../integrations/inwise/e2e/TEST_REPORT.md)

Meeting data remains in the user's local Inwise installation. Inwise provides conversational memory and action-ready context management.
5 changes: 5 additions & 0 deletions integrations/inwise/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
dist/
node_modules/
*.local.json
!e2e/sandbox/tools/
!e2e/sandbox/tools/**
115 changes: 115 additions & 0 deletions integrations/inwise/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Inwise OSS for QM

This directory is a first vertical slice for connecting a user's local Inwise OSS meeting memory to a QM sandbox without an Inwise Cloud account.

The bridge has three trust zones:

```text
QM sandbox self-hosted relay user's laptop
inwise CLI -- HTTPS --> opaque request router <-- HTTPS -- edge connector
| (cannot decrypt data) |
+-- encrypted X25519/AES-GCM envelopes ----------------------+-- local MCP
127.0.0.1 only
```

The CLI and laptop exchange X25519 public keys during a short-lived pairing. The user confirms a short authentication code calculated independently at both endpoints before the CLI permits a tool call. Meeting requests and responses are encrypted with AES-256-GCM before they reach the relay. After code confirmation, the relay cannot silently substitute its own keys or decrypt the payloads. The relay persists routing credentials as SHA-256 hashes and encrypted request envelopes in PostgreSQL; it never receives endpoint private keys or plaintext meeting data. It does see timing, device labels, pairing IDs, and ciphertext sizes.

## User experience

1. In a personal QM conversation, the user says, “Connect my Inwise.”
2. QM runs `inwise auth login`. It shows a short-lived pairing code and a laptop command.
3. With Inwise Desktop running, the user runs that command on the laptop. The edge verifies the local MCP endpoint, claims the code, stores device credentials locally, and prints a verification code.
4. In QM, the user runs `inwise auth confirm VERIFICATION_CODE`. QM rejects it if its independently calculated code differs. No Inwise query is allowed before this succeeds.
5. The user starts `inwise-qm-edge serve`. A production desktop integration should auto-start this worker after explicit approval.
6. The user can now ask, “What did we decide about the launch?” or “Prepare me for my meeting with Ada.” QM searches Inwise and answers with meeting context.
7. If the laptop or Inwise Desktop is offline, the request fails clearly. There is no cloud-data fallback.

The initial release is intentionally read-only and personal-scope only. Shared-channel access should remain disabled until QM can cryptographically bind the acting user and audience to the per-user credential.

## Build and test

Requires Node.js 22 or newer.

```bash
cd integrations/qm
npm ci
npm test
```

The deployed Docker proof additionally requires a current QM source checkout, Docker, and a running Inwise Desktop MCP endpoint:

```bash
QM_REPO=/path/to/yc-software/qm npm run test:deployed
```

Windows also needs a Node.js 24 Linux binary in WSL through `QM_WSL_NODE`. See the [deployed fixture](./e2e/README.md) and its [passing test report](./e2e/TEST_REPORT.md).

For local development, start a relay:

```bash
npm run build
INWISE_QM_PUBLIC_URL=http://127.0.0.1:8787 node dist/relay/index.js
```

In a separate shell, create the QM-side pairing:

```bash
INWISE_QM_CONFIG=./qm-credentials.local.json \
node dist/cli/index.js auth login --relay http://127.0.0.1:8787
```

On the laptop with Inwise Desktop running:

```bash
INWISE_QM_EDGE_CONFIG=./edge-credentials.local.json \
node dist/edge/index.js pair --relay http://127.0.0.1:8787 --code PAIRING_CODE
INWISE_QM_EDGE_CONFIG=./edge-credentials.local.json \
node dist/edge/index.js serve
```

Then refresh the QM-side status and query Inwise:

```bash
INWISE_QM_CONFIG=./qm-credentials.local.json node dist/cli/index.js auth confirm VERIFICATION_CODE
INWISE_QM_CONFIG=./qm-credentials.local.json node dist/cli/index.js auth status
INWISE_QM_CONFIG=./qm-credentials.local.json node dist/cli/index.js meetings search "launch"
```

## Relay deployment

The relay is stateless at the process layer and may run multiple replicas against the same PostgreSQL database. Configure:

- `PORT` — listener port, default `8787`.
- `INWISE_QM_PUBLIC_URL` — externally reachable HTTPS origin used in pairing instructions.
- `INWISE_QM_DATABASE_URL` (or `DATABASE_URL`) — required PostgreSQL connection string for pairing credentials, shared admission limits, and request lifecycle state.
- `INWISE_QM_REQUEST_TIMEOUT_MS` — request timeout, default 45 seconds.
- `INWISE_QM_REQUEST_LEASE_MS` — edge work lease, default 30 seconds. Abandoned leases can be reclaimed by another edge poller.

Terminate TLS at a trusted reverse proxy and restrict request body sizes there as well. Back up the database as a secret. Pairing creation is limited in the shared store to 10 attempts per source address per minute, five live pending pairings per source, 1,000 live pending pairings globally, 100 total pairings per source, and 10,000 total pairings globally. These hard caps also cover pairings an attacker immediately claims. Expired unclaimed pairings are removed transactionally before admission and by periodic cleanup.

Request submission is idempotent by `requestId`: the relay durably accepts an encrypted request with HTTP 202, the CLI polls for its result, and any relay replica can lease or answer it. Leases expire so queued/in-flight work survives relay restarts and abandoned workers. Expired requests and retained responses are cleaned from PostgreSQL.

## Add to a QM deployment directory

1. Build this package and install the resulting `inwise` binary plus its Node runtime files in the sandbox image. QM's deployment contract requires `install.binary` to exist on `PATH`; the descriptor alone does not install npm dependencies.
2. Copy `qm/tool.json` to `sandbox/tools/inwise/tool.json`.
3. Copy `skill/SKILL.md` to `sandbox/skills/inwise-meeting-memory/SKILL.md`.
4. Replace `relay.example.com` in `tool.json` with the relay's exact hostname.
5. Set the non-secret sandbox environment variable `INWISE_QM_RELAY_URL=https://your-relay.example`.
6. Run the QM deployment gates: `qm check`, `qm doctor`, publish the sandbox image, `qm plan`, `qm up --yes`, then `qm check --live`.

See QM's [deployment directory contract](https://github.com/yc-software/qm/blob/main/docs/deploy-directory.md) for the authoritative packaging rules.

## What this proves—and what remains

This implementation proves the core OSS path: local-only Inwise MCP, outbound laptop connectivity, sandbox CLI, authenticated pairing, encrypted routing, safe tool allowlisting, and QM skill/descriptor packaging.

Before calling it production-ready, add:

- Inwise Desktop settings UI, OS service auto-start, and a visible per-request activity indicator.
- Device list, revoke, credential rotation, and abuse monitoring.
- A model-driven agent-turn test using an operator-owned QM Fly sandbox app and provider credentials.
- A QM-enforced personal-scope identity binding instead of relying only on deployment policy and skill instructions.
- Security review and threat-model documentation for metadata exposure, compromised sandboxes, and compromised laptops.

Do not enable the three existing Inwise MCP write tools in this integration until a scoped approval and audit design exists.
97 changes: 97 additions & 0 deletions integrations/inwise/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { randomUUID } from "node:crypto";
import { decryptJson, derivePairingKey, encryptJson, requestAad, responseAad } from "../common/crypto.js";
import { fetchJson, HttpError, joinUrl } from "../common/http.js";
import {
isReadOnlyTool,
type BridgeRequest,
type BridgeResponse,
type EncryptedEnvelope,
type PairingFile,
type ReadOnlyTool,
} from "../common/protocol.js";

interface PairStatus {
status: "pending" | "paired";
edgePublicKey?: string;
deviceName?: string;
expiresAt?: string;
}

export async function refreshPairing(config: PairingFile): Promise<PairingFile> {
const status = await fetchJson<PairStatus>(
joinUrl(config.relayUrl, `/v1/pairings/${config.pairingId}`),
{ headers: { authorization: `Bearer ${config.cliToken}` } },
10_000,
);
if (status.status === "paired" && status.edgePublicKey) {
const sameKey = config.edgePublicKey === status.edgePublicKey;
return {
...config,
edgePublicKey: status.edgePublicKey,
deviceName: status.deviceName,
...(sameKey && config.confirmedAt ? { confirmedAt: config.confirmedAt } : { confirmedAt: undefined }),
};
}
return config;
}

export async function callInwise(
config: PairingFile,
toolName: string,
args: Record<string, unknown>,
): Promise<unknown> {
if (!isReadOnlyTool(toolName)) throw new Error(`Unsupported or write-capable tool: ${toolName}`);
if (!config.edgePublicKey) throw new Error("Pairing is waiting for approval on the Inwise laptop");
if (!config.confirmedAt)
throw new Error("Pairing keys are not verified. Compare the laptop code and run `inwise auth confirm CODE`");
const tool: ReadOnlyTool = toolName;
const requestId = randomUUID();
const key = derivePairingKey(config.cliPrivateKey, config.edgePublicKey, config.pairingId);
const command: BridgeRequest = { tool, args };
const envelope = encryptJson(key, command, requestAad(config.pairingId, requestId));
interface RelayRequestStatus {
requestId: string;
status: "pending" | "responded";
envelope?: EncryptedEnvelope;
}
const deadline = Date.now() + 50_000;
const retry = async (operation: () => Promise<RelayRequestStatus>): Promise<RelayRequestStatus> => {
while (true) {
try {
return await operation();
} catch (error) {
if (Date.now() >= deadline || (error instanceof HttpError && error.status < 500)) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
};
let response = await retry(() =>
fetchJson<RelayRequestStatus>(
joinUrl(config.relayUrl, `/v1/pairings/${config.pairingId}/requests`),
{
method: "POST",
headers: { authorization: `Bearer ${config.cliToken}` },
body: JSON.stringify({ requestId, envelope }),
},
10_000,
),
);
if (response.requestId !== requestId) throw new Error("Relay returned the wrong request id");
while (response.status === "pending") {
if (Date.now() >= deadline) throw new Error("Inwise Desktop is offline or did not respond in time");
await new Promise((resolve) => setTimeout(resolve, 250));
response = await retry(() =>
fetchJson<RelayRequestStatus>(
joinUrl(config.relayUrl, `/v1/pairings/${config.pairingId}/requests/${requestId}`),
{ headers: { authorization: `Bearer ${config.cliToken}` } },
10_000,
),
);
}
if (!response.envelope) throw new Error("Relay response is incomplete");
const result = decryptJson<BridgeResponse>(key, response.envelope, responseAad(config.pairingId, requestId));
if (!result.ok) throw new Error(result.error);
return result.result;
}
36 changes: 36 additions & 0 deletions integrations/inwise/cli/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { PairingFile } from "../common/protocol.js";

export function cliConfigPath(): string {
if (process.env.INWISE_QM_CONFIG) return process.env.INWISE_QM_CONFIG;
return join(
process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
"inwise-qm",
"credentials.json",
);
}

export function loadCliConfig(): PairingFile {
const path = cliConfigPath();
if (!existsSync(path))
throw new Error("Inwise is not connected. Run `inwise auth login` first.");
return JSON.parse(readFileSync(path, "utf8")) as PairingFile;
}

export function saveCliConfig(config: PairingFile): void {
const path = cliConfigPath();
mkdirSync(dirname(path), { recursive: true });
const temporary = `${path}.${process.pid}.tmp`;
writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, {
mode: 0o600,
});
renameSync(temporary, path);
}
Loading