diff --git a/start9/0.4/startos/actions/index.ts b/start9/0.4/startos/actions/index.ts index 630303d..ebd33ef 100644 --- a/start9/0.4/startos/actions/index.ts +++ b/start9/0.4/startos/actions/index.ts @@ -2,8 +2,10 @@ import { sdk } from '../sdk' import { buildSearchIndex } from './buildSearchIndex' import { refreshSearchIndex } from './refreshSearchIndex' import { resolveDuplicates } from './resolveDuplicates' +import { setAnthropicApiKey } from './setAnthropicApiKey' export const actions = sdk.Actions.of() .addAction(buildSearchIndex) .addAction(refreshSearchIndex) .addAction(resolveDuplicates) + .addAction(setAnthropicApiKey) diff --git a/start9/0.4/startos/actions/setAnthropicApiKey.ts b/start9/0.4/startos/actions/setAnthropicApiKey.ts new file mode 100644 index 0000000..2c8915a --- /dev/null +++ b/start9/0.4/startos/actions/setAnthropicApiKey.ts @@ -0,0 +1,129 @@ +import { i18n } from '../i18n' +import { sdk } from '../sdk' +import { DATA_MOUNT_PATH, IMAGE_ID } from '../utils' + +/** + * "Set Anthropic API Key (Architect)" action. + * + * The Architect agent (Phase-1 thesis generation) runs on Claude. The container + * entrypoint (docker_entrypoint.sh) loads the key ONCE at boot from + * /data/secrets/anthropic-api-key + * and exports it as ANTHROPIC_API_KEY into the long-running server process. + * + * This action lets a non-technical operator paste the key into a masked field + * straight from the StartOS UI — no SSH/terminal — and writes it to that file + * with owner-only (0600) permissions. The key never leaves the box. + * + * Two deliberate properties: + * - The secret is piped to the writer over STDIN (never argv/env), so it can't + * surface in a process listing. We strip stray CR/LF a paste may carry — + * exactly what the entrypoint already does when it reads the file back. + * - A service RESTART is REQUIRED for the change to take effect: the entrypoint + * reads the file only at startup, so the running server won't see a newly + * saved key until it is restarted. Both the metadata `warning` and the + * success `message` say so. + * + * Like the ingest actions, this runs in its OWN subcontainer with the main + * /data volume mounted read-write; it does NOT go through docker_entrypoint.sh. + */ + +const { InputSpec, Value } = sdk + +const KEY_FILE = `${DATA_MOUNT_PATH}/secrets/anthropic-api-key` + +// Single shell command: ensure /data/secrets exists + locked (700), write the +// key from stdin stripped of CR/LF, then 0600 the file. umask 077 makes the +// created file owner-only even before the explicit chmod. The `tr -d '\n\r'` +// mirrors how docker_entrypoint.sh reads the key back. +const WRITE_KEY_SCRIPT = + 'set -eu; umask 077; ' + + 'mkdir -p "$DATA_DIR/secrets"; chmod 700 "$DATA_DIR/secrets"; ' + + "tr -d '\\n\\r' > \"$DATA_DIR/secrets/anthropic-api-key\"; " + + 'chmod 600 "$DATA_DIR/secrets/anthropic-api-key"' + +export const inputSpec = InputSpec.of({ + apiKey: Value.text({ + name: i18n('Anthropic API Key'), + description: i18n( + 'Your Anthropic (Claude) API key. It is written to ' + + '/data/secrets/anthropic-api-key on this server (owner-only, 0600) and ' + + 'never leaves the box. Required for the Architect thesis-generation ' + + 'features. Leave blank to keep the current key unchanged.', + ), + warning: null, + required: true, + default: null, + // Camouflage the input with dots so the key is not shown on screen. + masked: true, + placeholder: 'sk-ant-...', + }), +}) + +export const setAnthropicApiKey = sdk.Action.withInput( + // id + 'set-anthropic-api-key', + + // metadata + async ({ effects }) => ({ + name: i18n('Set Anthropic API Key (Architect)'), + description: i18n( + 'Paste your Anthropic (Claude) API key to enable the Architect ' + + 'thesis-generation features. The key is stored only on this server at ' + + '/data/secrets/anthropic-api-key with owner-only (0600) permissions and ' + + 'is never sent anywhere except the Anthropic API at generation time. ' + + 'Restart the service after saving for the key to take effect.', + ), + warning: i18n( + 'After saving, restart Ten31 Database (Stop, then Start) so the new key is ' + + 'loaded. The running service does not pick up the key until it restarts.', + ), + allowedStatuses: 'any', + group: null, + visibility: 'enabled', + }), + + // form input specification + inputSpec, + + // pre-fill: never read the secret back out — always present a blank field. + async ({ effects, prefill }) => null, + + // execution + async ({ effects, input }) => { + const subcontainer = await sdk.SubContainer.of( + effects, + { imageId: IMAGE_ID }, + sdk.Mounts.of().mountVolume({ + volumeId: 'main', + subpath: null, + mountpoint: DATA_MOUNT_PATH, + readonly: false, + }), + 'ten31-database-set-anthropic-api-key', + ) + + try { + // The key is piped in over stdin (options.input) — never argv/env — so it + // cannot appear in a process listing. + await subcontainer.execFail( + ['/bin/sh', '-c', WRITE_KEY_SCRIPT], + { env: { DATA_DIR: DATA_MOUNT_PATH }, input: input.apiKey }, + 60 * 1000, + ) + } finally { + await subcontainer.destroy() + } + + return { + version: '1', + title: i18n('Anthropic API key saved'), + message: i18n( + `Your Anthropic API key was written to ${KEY_FILE} (permissions 0600). ` + + 'IMPORTANT: restart Ten31 Database now (Stop, then Start) so the ' + + 'Architect picks up the new key, then open the Thesis Workshop to ' + + 'generate.', + ), + result: null, + } + }, +) diff --git a/start9/0.4/startos/utils.ts b/start9/0.4/startos/utils.ts index 45c0fd0..5f7ac8e 100644 --- a/start9/0.4/startos/utils.ts +++ b/start9/0.4/startos/utils.ts @@ -14,8 +14,9 @@ export const PACKAGE_TITLE = 'Ten31 Database' // * 0.1.0:46 (packaging fix: ship full backend so migrations run + endpoints work) // * 0.1.0:47 (soft-delete instead of hard-delete; source-count diagnostics) // * 0.1.0:48 (entity model: investors vs people; fixes double-count) -// * Current: 0.1.0:49 (Architect: Claude thesis generation + Thesis Workshop screen) -export const PACKAGE_VERSION = '0.1.0:49' +// * 0.1.0:49 (Architect: Claude thesis generation + Thesis Workshop screen) +// * Current: 0.1.0:50 (Set Anthropic API Key UI action — no terminal needed) +export const PACKAGE_VERSION = '0.1.0:50' export const DATA_MOUNT_PATH = '/data' export const WEB_PORT = 8080 diff --git a/start9/0.4/startos/versions/index.ts b/start9/0.4/startos/versions/index.ts index 479d47d..38d9b46 100644 --- a/start9/0.4/startos/versions/index.ts +++ b/start9/0.4/startos/versions/index.ts @@ -10,8 +10,9 @@ import { v_0_1_0_46 } from './v0.1.0.46' import { v_0_1_0_47 } from './v0.1.0.47' import { v_0_1_0_48 } from './v0.1.0.48' import { v_0_1_0_49 } from './v0.1.0.49' +import { v_0_1_0_50 } from './v0.1.0.50' export const versionGraph = VersionGraph.of({ - current: v_0_1_0_49, - other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48], + current: v_0_1_0_50, + other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49], }) diff --git a/start9/0.4/startos/versions/v0.1.0.50.ts b/start9/0.4/startos/versions/v0.1.0.50.ts new file mode 100644 index 0000000..7a26742 --- /dev/null +++ b/start9/0.4/startos/versions/v0.1.0.50.ts @@ -0,0 +1,20 @@ +import { VersionInfo } from '@start9labs/start-sdk' + +// Adds a "Set Anthropic API Key (Architect)" StartOS action so the Claude key +// can be installed from the UI — paste it into a masked field and it is written +// to /data/secrets/anthropic-api-key (0600) on the box, no terminal/SSH needed. +// The Architect only ever sends Ten31's own thesis text + what you type to +// Claude; it does not read LP/contact/communication records. Restart the +// service after saving so the entrypoint loads the new key. No data migration. +export const v_0_1_0_50 = VersionInfo.of({ + version: '0.1.0:50', + releaseNotes: { + en_US: [ + 'Adds a "Set Anthropic API Key (Architect)" action: install your Claude API', + 'key from the StartOS UI (masked field, stored only on the server at', + '/data/secrets/anthropic-api-key) — no terminal needed. Restart the service', + 'after saving so the Architect picks it up.', + ].join(' '), + }, + migrations: { up: async () => {}, down: async () => {} }, +})