Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
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/**
113 changes: 113 additions & 0 deletions integrations/inwise/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 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 never receives the endpoint private keys. 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 a single Node process. Configure:

- `PORT` — listener port, default `8787`.
- `INWISE_QM_PUBLIC_URL` — externally reachable HTTPS origin used in pairing instructions.
- `INWISE_QM_STATE_FILE` — persistent pairing store, default `./data/qm-relay.json`.
- `INWISE_QM_REQUEST_TIMEOUT_MS` — request timeout, default 45 seconds.

Terminate TLS at a trusted reverse proxy and restrict request body sizes there as well. Back up the state file as a secret. Run exactly one relay process in this version: pending requests are held in memory, so horizontal scaling needs a shared broker.

## 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, relay key/rate limits, and abuse monitoring.
- A durable shared broker for multi-replica relay deployments.
- 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.
95 changes: 95 additions & 0 deletions integrations/inwise/cli/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { randomUUID } from "node:crypto";
import {
decryptJson,
derivePairingKey,
encryptJson,
requestAad,
responseAad,
} from "../common/crypto.js";
import { fetchJson, 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),
);
const response = await fetchJson<{
requestId: string;
envelope: EncryptedEnvelope;
}>(
joinUrl(config.relayUrl, `/v1/pairings/${config.pairingId}/requests`),
{
method: "POST",
headers: { authorization: `Bearer ${config.cliToken}` },
body: JSON.stringify({ requestId, envelope }),
},
50_000,
);
if (response.requestId !== requestId)
throw new Error("Relay returned the wrong request id");
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