Add Google Calendar integration and Claude docs

- New server/google_calendar.mjs wrapping googleapis v3 with auto token refresh
- Four new actions: calendar-list-events (auto-accept), calendar-create/update/delete-event (queue)
- bin/ccc-gcal-auth.mjs one-time OAuth2 consent flow helper
- config.example.json updated with google_calendar block
- server/config.mjs, index.mjs wired up following the same pattern as SMTP/mailer
- Bump version to 1.2.0
- Add CLAUDE.md and claude-info/ with architecture reference, feature plans, and contributing checklist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 15:33:36 +00:00
parent ba8c0701f8
commit 25891ece7e
12 changed files with 617 additions and 5 deletions

31
CLAUDE.md Normal file
View File

@@ -0,0 +1,31 @@
# claude-code-conduit
Supervised action bridge between Claude Code (in a Docker container) and the host system. Claude sends structured action requests; the server applies per-action policies and optionally holds them for human approval via a TUI queue manager.
## Quick orientation
- **Server** runs on the host (`bin/ccc-server.mjs`), listens on port 3015
- **Client** runs in the container (`bin/ccc-client.mjs`), sends signed JSON action requests
- **Actions** are defined in `server/actions.mjs` — this is the main place to add new capabilities
- **Auth** is HMAC-SHA256; secrets live in a JSON file, never env vars
- **Policies**: `auto-accept` (runs immediately), `queue` (held for human approval), `auto-deny`
## Adding a new action
Edit `server/actions.mjs`. Each action needs `description`, `params`, `policy`, and `handler`. The handler receives `(params, ctx)` where ctx has `{ caller, users, mail_perm_store, exec, mailer_send }`.
See [`claude-info/architecture.md`](claude-info/architecture.md) for full details.
See [`claude-info/contributing.md`](claude-info/contributing.md) for the checklist when adding features.
## Active / planned work
- [`claude-info/plan-google-calendar.md`](claude-info/plan-google-calendar.md) — Google Calendar integration ✓ implemented
## Running
```bash
ccc-server --config config.json
ccc-client '{"action": "list-actions"}'
```
Shell helpers (`ccc-open-terminal`, `ccc-edit-file`, `ccc-open-browser`) are available in the container via `~/.bashrc`.

View File

@@ -167,9 +167,42 @@ Built-in actions:
| `send-email` | auto-accept | `to`, `subject`, `body`, `topic` |
| `set-mail-permission` | auto-accept | `target_user`, `to`, `allow` (bool), `topic` (optional — omit to match any topic) |
| `get-mail-permissions` | auto-accept | `target_user` (optional) |
| `calendar-list-events` | auto-accept | `calendar_id` (default `primary`), `time_min`, `time_max`, `max_results` |
| `calendar-create-event` | queue | `summary`, `start`, `end` (ISO 8601), `description`, `calendar_id` |
| `calendar-update-event` | queue | `event_id`, `calendar_id`, `summary`, `start`, `end`, `description` |
| `calendar-delete-event` | queue | `event_id`, `calendar_id` |
`send-email` checks that the caller has a mail permission entry matching `(caller, to, topic)` before sending. Permissions are managed via `set-mail-permission`, which requires the caller to have `canApprove` over the target user — so only humans can grant/revoke permissions for agents.
Calendar mutation actions (`create`, `update`, `delete`) are `queue` policy — they require human approval in the TUI before executing. Listing is `auto-accept`.
### Google Calendar setup
Google Calendar support requires OAuth2 credentials from Google Cloud Console.
**One-time setup:**
1. Create a project at [Google Cloud Console](https://console.cloud.google.com), enable the Calendar API
2. Create an OAuth2 credential (Desktop app type) and download the JSON file
3. Run the auth helper to do the consent flow:
```bash
ccc-gcal-auth --credentials google-credentials.json --token google-token.json
```
This opens an authorization URL in the terminal. Open it in your browser, grant access, and the token is written automatically.
4. Add `google_calendar` to your `config.json`:
```json
"google_calendar": {
"credentials": "../google-credentials.json",
"token": "../google-token.json"
}
```
Paths are resolved relative to the config file. Tokens are refreshed automatically and written back to the token file.
### Adding actions
Edit `server/actions.mjs`. Each entry needs:
@@ -180,7 +213,7 @@ Edit `server/actions.mjs`. Each entry needs:
params: [{ name: 'foo', required: true, type: 'string' }],
policy: 'auto-accept', // or 'auto-deny' | 'queue'
handler: ({ foo }, ctx) => {
// ctx = { caller, users, mail_perm_store, mailer_send }
// ctx = { caller, users, mail_perm_store, exec, mailer_send, calendar }
// ctx is optional — omit the second argument if you don't need it
return { result: foo };
},

86
bin/ccc-gcal-auth.mjs Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
// One-time OAuth2 authorization flow for Google Calendar.
// Run this on the host to generate a token file for the server.
//
// Usage:
// ccc-gcal-auth --credentials google-credentials.json --token google-token.json
import { readFileSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { google } from 'googleapis';
const SCOPES = ['https://www.googleapis.com/auth/calendar'];
const PORT = 3016;
const REDIRECT_URI = `http://localhost:${PORT}/callback`;
function get_arg(argv, flag) {
const i = argv.indexOf(flag);
return i !== -1 ? argv[i + 1] : null;
}
const credentials_path = get_arg(process.argv, '--credentials');
const token_path = get_arg(process.argv, '--token');
if (!credentials_path || !token_path) {
console.error('Usage: ccc-gcal-auth --credentials <path> --token <output-path>');
process.exit(1);
}
let credentials;
try {
credentials = JSON.parse(readFileSync(credentials_path, 'utf8'));
} catch (err) {
console.error(`Cannot read credentials file: ${err.message}`);
process.exit(1);
}
const { client_id, client_secret } = credentials.installed ?? credentials.web;
const oauth2_client = new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);
const auth_url = oauth2_client.generateAuthUrl({
access_type: 'offline',
scope: SCOPES,
prompt: 'consent',
});
console.log('Open this URL in your browser to authorize Google Calendar access:\n');
console.log(auth_url);
console.log(`\nListening for redirect on http://localhost:${PORT}/callback ...`);
const server = createServer(async (req, res) => {
let url;
try {
url = new URL(req.url, `http://localhost:${PORT}`);
} catch (_) {
res.end('Bad request');
return;
}
if (url.pathname !== '/callback') {
res.end('Not found');
return;
}
const code = url.searchParams.get('code');
if (!code) {
res.writeHead(400);
res.end('Missing code parameter');
return;
}
try {
const { tokens } = await oauth2_client.getToken(code);
writeFileSync(token_path, JSON.stringify(tokens, null, '\t') + '\n', 'utf8');
res.end('Authorization successful. You may close this tab.');
console.log(`\nToken written to ${token_path}`);
server.close();
} catch (err) {
res.writeHead(500);
res.end(`Error: ${err.message}`);
console.error(`Token exchange failed: ${err.message}`);
server.close();
process.exit(1);
}
});
server.listen(PORT, '127.0.0.1');

113
claude-info/architecture.md Normal file
View File

@@ -0,0 +1,113 @@
# Architecture
## File layout
```
server/
index.mjs — Express app, routing, policy enforcement
actions.mjs — Action registry (add new actions here)
auth.mjs — HMAC-SHA256 middleware, canApprove checks
config.mjs — Loads config.json + secrets.json
helpers.mjs — exec(), path resolution, volume mapping
queue.mjs — In-memory queue for policy=queue actions
mail_perms.mjs — Persistent mail permission store (JSON file)
mailer.mjs — Nodemailer wrapper
bin/
ccc-server.mjs — Server entry point
ccc-client.mjs — CLI client (used by agent)
ccc-queue.mjs — TUI queue manager (used by human on host)
ccc-keygen.mjs — Secret generation / filtering utility
```
## Request lifecycle
1. Client signs request with HMAC-SHA256 (includes timestamp; replays rejected after 30s)
2. `auth.mjs` middleware validates signature, sets `req.conduit_user`
3. `POST /action` looks up the action definition in `actions.mjs`
4. Validates required params
5. Applies policy:
- `auto-accept` → runs handler immediately, returns result
- `queue` → enqueues, returns `{ status: 'queued', id }`; human approves/denies via TUI
- `auto-deny` → rejects immediately
## Action handler context (`ctx`)
```js
{
caller, // string — authenticated username
users, // object — full users map from secrets.json
mail_perm_store, // MailPermStore instance
exec, // (bin, args) => void — runs a host process (no-op in --dry-run)
mailer_send, // async (to, subject, body) => void
calendar, // Google_Calendar_Client | null — null if not configured
}
```
New modules are instantiated in `index.mjs` and added to `make_ctx()`.
## Config file (`config.json`)
```json
{
"secrets": "../secrets.json", // required — path to users/secrets
"mail_perms": "mail-perms.json", // optional — persists mail permissions
"smtp": { ... }, // optional — enables send-email action
"bind": "127.0.0.1",
"port": 3015
}
```
All relative paths are resolved from the config file's directory.
## Secrets file (`secrets.json`)
```json
{
"users": {
"agent": { "secret": "<hmac-key>", "canApprove": [] },
"user": { "secret": "<hmac-key>", "canApprove": ["agent"] }
}
}
```
`canApprove` lists whose queued actions a user may approve/deny.
## Path resolution
Container paths are translated to host paths via `VOLUME_MAPPING` in `server/helpers.mjs`. Paths outside known volumes are rejected. Default mapping:
| Container | Host |
|-----------|------|
| `/workspace` | `<CONTAINER_PATH>/workspace` |
| `/home/claude` | `<CONTAINER_PATH>/claude-home` |
Edit `CONTAINER_PATH` and `VOLUME_MAPPING` in `helpers.mjs` to match the local setup.
## Adding a new action
```js
// server/actions.mjs
'my-action': {
description: 'What it does',
params: [
{ name: 'foo', required: true, type: 'string' },
{ name: 'bar', required: false, type: 'path' },
],
policy: 'auto-accept', // or 'queue' | 'auto-deny'
handler: ({ foo, bar }, ctx) => {
// ctx is optional — omit second arg if unused
return { result: foo };
},
},
```
## Adding a new injectable service (like mailer or calendar)
1. Create `server/my_service.mjs` exporting a factory function
2. Extend `load_config` in `server/config.mjs` to read and return the new config block
3. Instantiate the service in `index.mjs` using the config values
4. Add it to the `make_ctx()` return object
5. Use it in action handlers via `ctx.my_service`
See [`contributing.md`](contributing.md) for the full checklist when adding any feature.

View File

@@ -0,0 +1,53 @@
# Contributing checklist
Use this when adding any non-trivial feature to CCC. Go through every section — skipping docs or config is a common mistake.
---
## Adding a new action (simple — no new service)
- [ ] Add the action definition to `server/actions.mjs`
- [ ] Update the actions table in `README.md`
- [ ] If the action has special behavior, add a note under the table
- [ ] Update `claude-info/architecture.md` if ctx shape changes
## Adding a new injectable service (e.g. a new API integration)
### Code
- [ ] Create `server/<service>.mjs` with a factory function `create_<service>(cfg)`
- [ ] Extend `server/config.mjs` to read and return the new config block (resolve paths relative to config dir)
- [ ] Import and instantiate in `server/index.mjs`; handle missing config gracefully (warn, set to null)
- [ ] Add the service to the `make_ctx()` return object in `index.mjs`
- [ ] Add actions to `server/actions.mjs`; guard with `if (!ctx.<service>) throw new Error('<service> not configured')`
- [ ] If setup requires a one-time manual step (OAuth, keygen, etc.) add a bin script in `bin/`
- [ ] Add new dependencies to `package.json` and add bin scripts to the `bin` map
### Config
- [ ] Add the new config block to `config.example.json`
### Documentation
- [ ] Update `README.md`:
- Add new actions to the actions table
- Add a setup section explaining prerequisites and one-time steps
- Update the `ctx` comment in "Adding actions" if ctx changed
- [ ] Update `claude-info/architecture.md`:
- Add the new service to the ctx shape block
- Note any new files in the file layout
- [ ] Update or add a plan file in `claude-info/` — mark status as **implemented**
- [ ] Update `CLAUDE.md` — mark the plan entry as done
### Git
- [ ] `git status` — read every line before staging
- [ ] Stage files explicitly by name (never `git add -A` or `git add .`)
- [ ] Bump version in `package.json` if this is a meaningful release
- [ ] Commit with a clear message describing what was added
- [ ] Check git identity is set correctly for this repo (`git config user.name`)
### After commit
- [ ] Tell the user to run `npm install` if new dependencies were added
- [ ] Tell the user about any one-time setup steps required before the feature works

View File

@@ -0,0 +1,126 @@
# Plan: Google Calendar integration
Status: **implemented**
## Overview
Add Google Calendar read/write support as CCC actions. Follows the same pattern as the SMTP/mail integration: credentials in config, a wrapper module, new actions in `actions.mjs`.
Listing events is `auto-accept` (read-only). All mutations are `queue` — require human approval in the TUI before executing.
---
## Step 1 — Google OAuth2 setup (one-time manual)
1. Create a Google Cloud project, enable the Calendar API
2. Create an OAuth2 credential (Desktop app type), download the JSON
3. Run `ccc-gcal-auth` (see Step 7) to do the consent flow and write `google-token.json`
4. Point config at both files (see Step 2)
---
## Step 2 — Config extension
Add to `config.json` (and `config.example.json`):
```json
"google_calendar": {
"credentials": "../google-credentials.json",
"token": "../google-token.json"
}
```
Both paths resolved relative to the config file, same as `secrets` and `mail_perms`.
---
## Step 3 — New module: `server/google_calendar.mjs`
Wraps the `googleapis` npm package. Exports:
```js
export function create_calendar_client(cfg)
```
- Loads `cfg.credentials_path` and `cfg.token_path`
- Creates an OAuth2 client
- Registers `tokens` event to persist refreshed tokens back to disk automatically
- Returns a thin wrapper with methods:
- `list_events({ calendar_id, time_min, time_max, max_results })`
- `create_event({ calendar_id, summary, start, end, description })`
- `update_event({ calendar_id, event_id, summary, start, end, description })`
- `delete_event({ calendar_id, event_id })`
`start` and `end` are ISO 8601 strings (e.g. `"2026-05-21T14:00:00+02:00"`).
---
## Step 4 — Config loader update (`server/config.mjs`)
Extend `load_config` to resolve `google_calendar.credentials` and `google_calendar.token` paths and return them (or `null` if the block is absent).
---
## Step 5 — Server wiring (`server/index.mjs`)
```js
import { create_calendar_client } from './google_calendar.mjs';
const calendar = cfg.google_calendar
? create_calendar_client(cfg.google_calendar)
: null;
// In make_ctx():
return { caller, users, mail_perm_store, exec, mailer_send, calendar };
```
---
## Step 6 — New actions (`server/actions.mjs`)
| Action | Policy | Required params | Optional params |
|---|---|---|---|
| `calendar-list-events` | `auto-accept` | — | `calendar_id` (default `primary`), `time_min`, `time_max`, `max_results` |
| `calendar-create-event` | `queue` | `summary`, `start`, `end` | `calendar_id`, `description` |
| `calendar-update-event` | `queue` | `event_id` | `calendar_id`, `summary`, `start`, `end`, `description` |
| `calendar-delete-event` | `queue` | `event_id` | `calendar_id` |
All handlers guard with:
```js
if (!ctx.calendar) { throw new Error('Google Calendar not configured'); }
```
---
## Step 7 — Auth helper: `bin/ccc-gcal-auth.mjs`
One-time setup script run on the host:
```bash
ccc-gcal-auth --credentials google-credentials.json --token google-token.json
```
- Reads the downloaded OAuth credentials JSON
- Generates an authorization URL, prints it for the user to open
- Starts a local HTTP server to receive the redirect with the auth code
- Exchanges the code for tokens and writes `google-token.json`
Add to `bin` map in `package.json`.
---
## Step 8 — Dependencies
```bash
npm install googleapis
```
Add to `package.json` dependencies.
---
## Open questions
- Token storage: separate `google-token.json` (current plan) vs folded into `secrets.json`?
- Should `calendar-create-event` be `queue` or `auto-accept`? (Currently `queue` for safety)
- Multi-calendar support needed beyond `primary`?

View File

@@ -8,6 +8,10 @@
"auth": { "user": "relay@example.com", "pass": "<password>" },
"from": "agent@example.com"
},
"google_calendar": {
"credentials": "../google-credentials.json",
"token": "../google-token.json"
},
"bind": "127.0.0.1",
"port": 3015
}

View File

@@ -1,6 +1,6 @@
{
"name": "claude-code-conduit",
"version": "1.1.0",
"version": "1.2.0",
"description": "A supervised action bridge between Claude Code and the host system",
"type": "module",
"scripts": {
@@ -11,11 +11,13 @@
"ccc-server": "bin/ccc-server.mjs",
"ccc-client": "bin/ccc-client.mjs",
"ccc-queue": "bin/ccc-queue.mjs",
"ccc-keygen": "bin/ccc-keygen.mjs"
"ccc-keygen": "bin/ccc-keygen.mjs",
"ccc-gcal-auth": "bin/ccc-gcal-auth.mjs"
},
"dependencies": {
"blessed": "^0.1.81",
"express": "^5.2.1",
"googleapis": "^144.0.0",
"nodemailer": "^8.0.2"
}
}

View File

@@ -120,4 +120,68 @@ export const actions = {
return { permissions: target_user ? all.filter(e => e.user === target_user) : all };
},
},
'calendar-list-events': {
description: 'List upcoming calendar events',
params: [
{ name: 'calendar_id', required: false, type: 'string' },
{ name: 'time_min', required: false, type: 'string' },
{ name: 'time_max', required: false, type: 'string' },
{ name: 'max_results', required: false, type: 'number' },
],
policy: 'auto-accept',
handler: async ({ calendar_id, time_min, time_max, max_results }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
const events = await calendar.list_events({ calendar_id, time_min, time_max, max_results });
return { events };
},
},
'calendar-create-event': {
description: 'Create a new calendar event',
params: [
{ name: 'summary', required: true, type: 'string' },
{ name: 'start', required: true, type: 'string' },
{ name: 'end', required: true, type: 'string' },
{ name: 'description', required: false, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
],
policy: 'queue',
handler: async ({ summary, start, end, description, calendar_id }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
const event = await calendar.create_event({ calendar_id, summary, start, end, description });
return { event };
},
},
'calendar-update-event': {
description: 'Update fields on an existing calendar event',
params: [
{ name: 'event_id', required: true, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
{ name: 'summary', required: false, type: 'string' },
{ name: 'start', required: false, type: 'string' },
{ name: 'end', required: false, type: 'string' },
{ name: 'description', required: false, type: 'string' },
],
policy: 'queue',
handler: async ({ event_id, calendar_id, summary, start, end, description }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
const event = await calendar.update_event({ calendar_id, event_id, summary, start, end, description });
return { event };
},
},
'calendar-delete-event': {
description: 'Delete a calendar event',
params: [
{ name: 'event_id', required: true, type: 'string' },
{ name: 'calendar_id', required: false, type: 'string' },
],
policy: 'queue',
handler: async ({ event_id, calendar_id }, { calendar }) => {
if (!calendar) { throw new Error('Google Calendar not configured'); }
return calendar.delete_event({ calendar_id, event_id });
},
},
};

View File

@@ -46,10 +46,23 @@ export function load_config(file_path) {
? resolve(config_dir, parsed.mail_perms)
: null;
let google_calendar = null;
if (parsed.google_calendar) {
const { credentials, token } = parsed.google_calendar;
if (!credentials || !token) {
throw new Error("Config 'google_calendar' must have 'credentials' and 'token' fields");
}
google_calendar = {
credentials_path: resolve(config_dir, credentials),
token_path: resolve(config_dir, token),
};
}
return {
users: secrets.users,
smtp: parsed.smtp ?? null,
mail_perms_path,
google_calendar,
bind: parsed.bind ?? null,
port: parsed.port ?? null,
};

View File

@@ -0,0 +1,76 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { google } from 'googleapis';
// Returns a calendar client wrapping the Google Calendar API v3.
// cfg = { credentials_path, token_path }
export function create_calendar_client({ credentials_path, token_path }) {
const credentials = JSON.parse(readFileSync(credentials_path, 'utf8'));
const { client_id, client_secret, redirect_uris } = credentials.installed ?? credentials.web;
const oauth2_client = new google.auth.OAuth2(client_id, client_secret, redirect_uris[0]);
const token = JSON.parse(readFileSync(token_path, 'utf8'));
oauth2_client.setCredentials(token);
// Persist refreshed tokens automatically
oauth2_client.on('tokens', (new_tokens) => {
let current = {};
try {
current = JSON.parse(readFileSync(token_path, 'utf8'));
} catch (_) {}
const merged = { ...current, ...new_tokens };
writeFileSync(token_path, JSON.stringify(merged, null, '\t') + '\n', 'utf8');
});
const cal = google.calendar({ version: 'v3', auth: oauth2_client });
async function list_events({ calendar_id = 'primary', time_min, time_max, max_results = 10 } = {}) {
const res = await cal.events.list({
calendarId: calendar_id,
timeMin: time_min,
timeMax: time_max,
maxResults: max_results,
singleEvents: true,
orderBy: 'startTime',
});
return res.data.items ?? [];
}
async function create_event({ calendar_id = 'primary', summary, start, end, description }) {
const res = await cal.events.insert({
calendarId: calendar_id,
requestBody: {
summary,
description,
start: { dateTime: start },
end: { dateTime: end },
},
});
return res.data;
}
async function update_event({ calendar_id = 'primary', event_id, summary, start, end, description }) {
const patch = {};
if (summary !== undefined) { patch.summary = summary; }
if (description !== undefined) { patch.description = description; }
if (start !== undefined) { patch.start = { dateTime: start }; }
if (end !== undefined) { patch.end = { dateTime: end }; }
const res = await cal.events.patch({
calendarId: calendar_id,
eventId: event_id,
requestBody: patch,
});
return res.data;
}
async function delete_event({ calendar_id = 'primary', event_id }) {
await cal.events.delete({
calendarId: calendar_id,
eventId: event_id,
});
return { deleted: true, event_id };
}
return { list_events, create_event, update_event, delete_event };
}

View File

@@ -6,6 +6,7 @@ import { create_auth_middleware, check_can_approve } from './auth.mjs';
import { create_mailer } from './mailer.mjs';
import { exec as real_exec } from './helpers.mjs';
import { load_mail_perms } from './mail_perms.mjs';
import { create_calendar_client } from './google_calendar.mjs';
function get_arg(argv, flag) {
const i = argv.indexOf(flag);
@@ -29,7 +30,7 @@ try {
process.exit(1);
}
const { users, smtp, mail_perms_path } = cfg;
const { users, smtp, mail_perms_path, google_calendar: gcal_cfg } = cfg;
const PORT = process.env.CONDUIT_PORT || cfg.port || 3015;
const BIND = process.env.CONDUIT_BIND || cfg.bind || '127.0.0.1';
@@ -47,6 +48,16 @@ if (!mail_perms_path) {
const real_mailer_send = create_mailer(smtp);
let calendar = null;
if (gcal_cfg) {
try {
calendar = create_calendar_client(gcal_cfg);
console.log('Google Calendar client initialized');
} catch (err) {
console.error(`Warning: Failed to initialize Google Calendar client: ${err.message}`);
}
}
const app = express();
app.use(express.json({
verify: (req, _res, buf) => {
@@ -70,7 +81,7 @@ function make_ctx(caller) {
const mailer_send = DRY_RUN
? async (to, subject) => console.log(`[${ts()}] [DRY-RUN] send-mail: to=${to} subject=${JSON.stringify(subject)}`)
: real_mailer_send;
return { caller, users, mail_perm_store, exec, mailer_send };
return { caller, users, mail_perm_store, exec, mailer_send, calendar };
}
async function run_action(def, params, ctx) {