Skip to content
Merged
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
30 changes: 30 additions & 0 deletions agentic/pi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ const dbTools = createDbTools({
pi.use(dbTools);
```

## Project context

The tools need a bound database: an access key, a database id, and optionally endpoint/name pins. Where those values come from is the host's choice — `resolveProjectContext` takes values, not a directory:

```ts
import { fromEnvironment, resolveProjectContext } from '@agentic-kit/pi';

// headless: container, Job, CI — nothing on disk, nothing to commit
await resolveProjectContext(fromEnvironment());

// local project: read <cwd>/.env (what Desktop and the CLI do)
await resolveProjectContext(cwd);

// anything else: a record, or a lookup function into a secret store
await resolveProjectContext((name) => vault.get(name));
```

Injected variables carry a `CONSTRUCTIVE_` prefix; the bare names are also accepted (that is what the scaffolder writes into a project `.env`), with the prefixed spelling winning:

| Variable | Required | Purpose |
| --- | --- | --- |
| `CONSTRUCTIVE_ACCESS_TOKEN` | yes | project data-plane key |
| `CONSTRUCTIVE_DATABASE_ID` | yes | bound database |
| `CONSTRUCTIVE_DATABASE_NAME` | no | derives the per-db data endpoint |
| `CONSTRUCTIVE_API_ENDPOINT` | no | data-plane api pin |
| `CONSTRUCTIVE_MODULES_ENDPOINT` | no | data-plane modules pin |
| `CONSTRUCTIVE_OWNER_ID` | no | owner fallback when the probe omits it |

Endpoint pins from the source apply to the **data plane only**. The control plane (binding probe, schema resolution, blueprint/schema tools) always uses the host's `backendConfig()` and the account bearer, so an untrusted cloned project cannot redirect it.

## Provisioning

`provision_database` requests a database through the `requestDatabase` mutation on the api endpoint. When the requested module set matches a cataloged preset, the backend claims a warm pre-baked database in seconds. Otherwise a background job provisions the database cold. The tool polls the provision ticket on the modules endpoint until the database and its owner bootstrap are complete. Then it writes the credentials to the project `.env` and returns.
Expand Down
147 changes: 147 additions & 0 deletions agentic/pi/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';

jest.mock('../src/db-probe', () => ({ probeDatabase: jest.fn() }));
jest.mock('@constructive-io/sdk', () => ({
api: { createClient: jest.fn() },
auth: { createClient: jest.fn() },
modules: { createClient: jest.fn(() => ({ kind: 'modules' })) },
}));

import { api } from '@constructive-io/sdk';

import {
CONTEXT_ENV_PREFIX,
fromEnvFile,
fromEnvironment,
resolveProjectContext,
} from '../src/context';
import { probeDatabase } from '../src/db-probe';
import { configureHost } from '../src/host';

const mockProbe = probeDatabase as jest.MockedFunction<typeof probeDatabase>;
const mockCreateClient = api.createClient as jest.Mock;

const schemaClient = {
schema: {
findMany: () => ({
execute: async () => ({
ok: true,
data: { schemas: { nodes: [{ id: 'schema-1', name: 'app_public' }] } },
}),
}),
},
};

let dir: string;

beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), 'pi-context-'));
mockProbe.mockResolvedValue({ outcome: 'found', name: 'myapp', ownerId: 'u1' });
mockCreateClient.mockReturnValue(schemaClient);
configureHost({
account: () => ({ userId: 'u1', accessToken: 'account-bearer' }),
backendConfig: () => ({
apiEndpoint: 'http://api.localhost:3000/graphql',
modulesEndpoint: 'http://modules.localhost:3000/graphql',
}),
});
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
jest.clearAllMocks();
});

describe('resolveProjectContext sources', () => {
it('resolves from injected environment variables, no file needed', async () => {
const resolved = await resolveProjectContext(
fromEnvironment({
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key',
[`${CONTEXT_ENV_PREFIX}DATABASE_ID`]: 'db-1',
[`${CONTEXT_ENV_PREFIX}DATABASE_NAME`]: 'myapp',
}),
);

expect(resolved.context).toMatchObject({
accessToken: 'project-key',
databaseId: 'db-1',
databaseName: 'myapp',
schemaId: 'schema-1',
dataEndpoint: 'http://api-myapp.localhost:3000/graphql',
});
});

it('resolves from a project .env when given a cwd (unchanged host behavior)', async () => {
writeFileSync(path.join(dir, '.env'), 'ACCESS_TOKEN=file-key\nDATABASE_ID=db-file\n');

const resolved = await resolveProjectContext(dir);

expect(resolved.context).toMatchObject({ accessToken: 'file-key', databaseId: 'db-file' });
});

it('prefers the prefixed name over the bare one', async () => {
const resolved = await resolveProjectContext({
ACCESS_TOKEN: 'bare',
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'prefixed',
DATABASE_ID: 'db-1',
});

expect(resolved.context?.accessToken).toBe('prefixed');
});

it('accepts a lookup function as the source', async () => {
const values: Record<string, string> = {
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'from-fn',
[`${CONTEXT_ENV_PREFIX}DATABASE_ID`]: 'db-1',
};

const resolved = await resolveProjectContext((name) => values[name]);

expect(resolved.context?.accessToken).toBe('from-fn');
});

it('pins the data-plane endpoints from the source, never the control plane', async () => {
const resolved = await resolveProjectContext({
[`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key',
[`${CONTEXT_ENV_PREFIX}DATABASE_ID`]: 'db-1',
[`${CONTEXT_ENV_PREFIX}API_ENDPOINT`]: 'http://api.evil.test/graphql',
});

expect(resolved.context?.apiEndpoint).toBe('http://api.evil.test/graphql');
// control-plane clients + probe stay on the host-configured backend
expect(mockCreateClient).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'http://api.localhost:3000/graphql' }),
);
expect(mockProbe).toHaveBeenCalledWith(
expect.objectContaining({ endpoint: 'http://api.localhost:3000/graphql' }),
);
});

it('reports no-env when a cwd has no .env', async () => {
const resolved = await resolveProjectContext(dir);

expect(resolved.context).toBeNull();
expect(resolved.code).toBe('no-env');
});

it('reports missing-credentials when the source carries no credentials', async () => {
const resolved = await resolveProjectContext(fromEnvironment({ PATH: '/usr/bin' }));

expect(resolved.context).toBeNull();
expect(resolved.code).toBe('missing-credentials');
});
});

describe('fromEnvFile', () => {
it('parses the project .env, quotes and comments included', async () => {
writeFileSync(path.join(dir, '.env'), '# comment\nACCESS_TOKEN="quoted key"\nDATABASE_ID=db-1\n');

expect(await fromEnvFile(dir)).toEqual({ ACCESS_TOKEN: 'quoted key', DATABASE_ID: 'db-1' });
});

it('returns null when the file is absent', async () => {
expect(await fromEnvFile(dir)).toBeNull();
});
});
1 change: 1 addition & 0 deletions agentic/pi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"test:watch": "jest --watch"
},
"dependencies": {
"12factor-env": "workspace:^",
"@agentic-kit/harness": "workspace:^",
"@constructive-io/graphql-query": "workspace:^",
"@constructive-io/sdk": "workspace:^",
Expand Down
104 changes: 73 additions & 31 deletions agentic/pi/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';

import { api, auth, modules } from '@constructive-io/sdk';
import { parseDotenv } from '12factor-env/dotenv';

import { probeDatabase } from './db-probe';
import { DEFAULT_DATA_TOKEN_SKEW_MS, getHost } from './host';
Expand Down Expand Up @@ -41,24 +42,58 @@ export type ResolveResult = {
code?: ProjectContextFailureCode;
};

function parseEnv(source: string): Record<string, string> {
const env: Record<string, string> = {};
for (const rawLine of source.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const eq = line.indexOf('=');
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
env[key] = value;
// The project values pi needs, and where they come from. A scaffolded project
// folder carries them in `.env` (desktop); a headless host — a container, a
// Job, CI — injects them as real environment variables, so no credential is
// ever written into a git clone the agent could commit. Both lanes produce the
// same record, which is why the resolver takes VALUES, not a directory.
export const CONTEXT_ENV_KEYS = [
'ACCESS_TOKEN',
'DATABASE_ID',
'API_ENDPOINT',
'MODULES_ENDPOINT',
'DATABASE_NAME',
'OWNER_ID',
] as const;

export type ContextEnvKey = (typeof CONTEXT_ENV_KEYS)[number];

/** Prefix for injected variables: `ACCESS_TOKEN` is far too generic to claim in
* a shared process environment, `CONSTRUCTIVE_ACCESS_TOKEN` is not. Inside a
* project `.env` the bare names stay readable (and are what the scaffolder
* writes), so both spellings resolve, prefixed winning. */
export const CONTEXT_ENV_PREFIX = 'CONSTRUCTIVE_';

/** Values keyed by name, or a lookup function (a host with a secret store). */
export type ContextSource =
| Record<string, string | undefined>
| ((name: string) => string | undefined);

const lookup = (source: ContextSource): ((name: string) => string | undefined) =>
typeof source === 'function' ? source : (name) => source[name];

const readContextValue = (source: ContextSource, key: ContextEnvKey): string | undefined => {
const get = lookup(source);
const value = get(`${CONTEXT_ENV_PREFIX}${key}`) ?? get(key);
return value ? value : undefined;
};

/** Use the process environment (or any injected record) as the source. */
export function fromEnvironment(
environment: Record<string, string | undefined> = process.env,
): ContextSource {
return environment;
}

/** Read a project `.env` as the source. The file is authoritative here — the
* key in it belongs to whatever backend wrote it, so it is not merged under
* the ambient environment. Returns null when the file is absent. */
export async function fromEnvFile(cwd: string): Promise<ContextSource | null> {
try {
return parseDotenv(await readFile(path.join(cwd, '.env'), 'utf8'));
} catch {
return null;
}
return env;
}

export type ResolveOptions = {
Expand All @@ -71,14 +106,20 @@ export type ResolveOptions = {
plane?: 'control' | 'data';
};

/**
* Resolve the project context from injected values, or from a project folder.
*
* Passing a `cwd` string keeps the original behavior (read `<cwd>/.env`) so
* existing hosts — Constructive Desktop, the confirm gate — are unchanged.
* Headless hosts pass values instead: `fromEnvironment()` for a container or
* Job, an explicit record for anything else.
*/
export async function resolveProjectContext(
cwd: string,
input: string | ContextSource,
options: ResolveOptions = {},
): Promise<ResolveResult> {
let source: string;
try {
source = await readFile(path.join(cwd, '.env'), 'utf8');
} catch {
const source = typeof input === 'string' ? await fromEnvFile(input) : input;
if (!source) {
return {
context: null,
reason:
Expand All @@ -87,20 +128,21 @@ export async function resolveProjectContext(
};
}

const env = parseEnv(source);
const env = Object.fromEntries(
CONTEXT_ENV_KEYS.map((key) => [key, readContextValue(source, key)]),
) as Record<ContextEnvKey, string | undefined>;
const accessToken = env.ACCESS_TOKEN;
const databaseId = env.DATABASE_ID;

if (!accessToken || !databaseId) {
return {
context: null,
reason:
'Project is not connected to a Constructive database yet (missing ACCESS_TOKEN/DATABASE_ID in .env). Provision the database first, then retry.',
reason: `Project is not connected to a Constructive database yet (missing ${CONTEXT_ENV_PREFIX}ACCESS_TOKEN/${CONTEXT_ENV_PREFIX}DATABASE_ID in the environment, or ACCESS_TOKEN/DATABASE_ID in .env). Provision the database first, then retry.`,
code: 'missing-credentials',
};
}

// A per-project .env pin wins for the DATA plane (the .env key belongs to
// A source-supplied pin wins for the DATA plane (the access key belongs to
// whatever backend wrote it); otherwise fall back to the app's backend-config
// store (environment-aware) so app + harness share one endpoint source.
const host = getHost();
Expand All @@ -111,9 +153,9 @@ export async function resolveProjectContext(
// The whole control plane (binding probe, schema resolution, blueprint/schema
// tools) authenticates with the ACCOUNT bearer: the platform api rejects
// per-database keys, so the project ACCESS_TOKEN can never act on metaschema
// surfaces. The .env key stays in the context for the data plane only. The
// bearer is only ever sent to the app-configured backend — never a
// .env-pinned endpoint, which an untrusted cloned project controls.
// surfaces. The source's key stays in the context for the data plane only.
// The bearer is only ever sent to the app-configured backend — never a
// source-pinned endpoint, which an untrusted cloned project controls.
const controlApiEndpoint = backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
const controlModulesEndpoint = backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT;
const account = host.account();
Expand All @@ -131,7 +173,7 @@ export async function resolveProjectContext(

const apiClient = api.createClient({ endpoint: controlApiEndpoint, headers: controlHeaders });

// Always probe the bound database — the .env stamp proves the project WAS
// Always probe the bound database — the DATABASE_ID stamp proves it WAS
// bound, never that the database still exists (backend refresh, deletion,
// revoked key). The probe is the single source of binding health.
const probe = await probeDatabase({
Expand Down Expand Up @@ -159,7 +201,7 @@ export async function resolveProjectContext(
// never shown in the Schemas tab or written to by the agent. The project's
// local blueprint survives account changes, so recovery is a reprovision
// under the signed-in account. Owner truth comes from the probe (backend),
// not the .env stamp. Data-plane resolution skips this gate — see
// not the source's stamp. Data-plane resolution skips this gate — see
// ResolveOptions.
const sessionUserId = account?.userId;
if (
Expand Down
7 changes: 7 additions & 0 deletions agentic/pi/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,16 @@ export {
createConfirmGate,
} from './confirm-gate';
export {
CONTEXT_ENV_KEYS,
CONTEXT_ENV_PREFIX,
type ContextEnvKey,
type ContextSource,
deriveSubdomainEndpoint,
fromEnvFile,
fromEnvironment,
type ModulesClient,
type ProjectContext,
type ProjectContextFailureCode,
resolveDataToken,
resolveProjectContext,
} from './context';
Expand Down
Loading
Loading