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
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "integration-source-deployer",
"intent": "documentation",
"tags": ["documentation"],
"description": "Fixture: persona declaring a GitHub integration with no explicit source. Exercises the default-injection path that fills in { kind: 'deployer_user' } during parse.",
"harness": "claude",
"model": "anthropic/claude-3-5-sonnet",
"systemPrompt": "Fixture persona for IntegrationSource default-injection. Not used at runtime.",
"harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 },
"cloud": true,
"integrations": {
"github": {
"scope": { "repo": "AgentWorkforce/workforce" },
"triggers": [{ "on": "pull_request.opened" }]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"id": "integration-source-service-account",
"intent": "documentation",
"tags": ["documentation"],
"description": "Fixture: persona declaring a GitHub integration resolved via a named workspace service account ('release-bot').",
"harness": "claude",
"model": "anthropic/claude-3-5-sonnet",
"systemPrompt": "Fixture persona for IntegrationSource={ kind: 'workspace_service_account', name: 'release-bot' }. Not used at runtime.",
"harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 },
"cloud": true,
"integrations": {
"github": {
"source": { "kind": "workspace_service_account", "name": "release-bot" },
"scope": { "repo": "AgentWorkforce/workforce" },
"triggers": [{ "on": "pull_request.opened" }]
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "integration-source-workspace",
"intent": "documentation",
"tags": ["documentation"],
"description": "Fixture: persona declaring a Slack integration resolved from the workspace's default workspace_integrations row.",
"harness": "claude",
"model": "anthropic/claude-3-5-sonnet",
"systemPrompt": "Fixture persona for IntegrationSource={ kind: 'workspace' }. Not used at runtime.",
"harnessSettings": { "reasoning": "medium", "timeoutSeconds": 300 },
"cloud": true,
"integrations": {
"slack": {
"source": { "kind": "workspace" },
"triggers": [{ "on": "app_mention" }]
}
}
}
3 changes: 3 additions & 0 deletions packages/persona-kit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export type {
Harness,
HarnessSettings,
HarnessSkillTarget,
IntegrationSource,
McpServerSpec,
PermissionMode,
PersonaContext,
Expand Down Expand Up @@ -50,6 +51,7 @@ export {
assertSidecarPath,
deepFreeze,
INPUT_NAME_RE,
INTEGRATION_SOURCE_NAME_RE,
isHarness,
isIntent,
isObject,
Expand All @@ -58,6 +60,7 @@ export {
parseHarnessSettings,
parseInputs,
parseIntegrationConfig,
parseIntegrationSource,
parseIntegrationTrigger,
parseIntegrations,
parseMcpServers,
Expand Down
117 changes: 116 additions & 1 deletion packages/persona-kit/src/parse.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
assertInputName,
assertSidecarPath,
Expand Down Expand Up @@ -470,7 +472,10 @@ test('parseIntegrations preserves scope + triggers; rejects empty trigger arrays
assert.equal(i?.github.scope?.repo, 'org/r');
assert.equal(i?.github.triggers?.length, 2);
assert.equal(i?.github.triggers?.[1].match, '@mention');
assert.deepEqual(i?.linear, {});
// Default-injected source keeps existing personas resolving against
// the deploying user's `user_integrations` row.
assert.deepEqual(i?.github.source, { kind: 'deployer_user' });
assert.deepEqual(i?.linear, { source: { kind: 'deployer_user' } });

assert.throws(
() =>
Expand All @@ -490,6 +495,116 @@ test('parseIntegrations preserves scope + triggers; rejects empty trigger arrays
);
});

test('parseIntegrations default-injects source=deployer_user when the persona omits it', () => {
const i = parseIntegrations({ github: {} }, 'integrations');
assert.deepEqual(i?.github.source, { kind: 'deployer_user' });
});

test('parseIntegrations round-trips all three valid IntegrationSource kinds', () => {
const i = parseIntegrations(
{
github: { source: { kind: 'deployer_user' } },
slack: { source: { kind: 'workspace' } },
linear: {
source: { kind: 'workspace_service_account', name: 'release-bot' }
}
},
'integrations'
);
assert.deepEqual(i?.github.source, { kind: 'deployer_user' });
assert.deepEqual(i?.slack.source, { kind: 'workspace' });
assert.deepEqual(i?.linear.source, {
kind: 'workspace_service_account',
name: 'release-bot'
});
});

test('parseIntegrations rejects an unknown source.kind with a precise field path', () => {
assert.throws(
() =>
parseIntegrations(
{ github: { source: { kind: 'org' } } },
'integrations'
),
/integrations\.github\.source\.kind must be one of: deployer_user, workspace, workspace_service_account/
);
});

test('parseIntegrations rejects workspace_service_account missing name', () => {
assert.throws(
() =>
parseIntegrations(
{ github: { source: { kind: 'workspace_service_account' } } },
'integrations'
),
/integrations\.github\.source\.name must be a non-empty string when kind="workspace_service_account"/
);
});

test('parseIntegrations rejects workspace_service_account with non-kebab-case name', () => {
assert.throws(
() =>
parseIntegrations(
{
github: {
source: { kind: 'workspace_service_account', name: 'Release_Bot' }
}
},
'integrations'
),
/integrations\.github\.source\.name must be kebab-case/
);
});

test('IntegrationSource fixtures round-trip through parsePersonaSpec', () => {
// Fixtures live under src/__fixtures__/personas/. The compiled test
// sits at dist/parse.test.js, so resolve back through the package root.
const here = dirname(fileURLToPath(import.meta.url));
const fixturesRoot = resolve(here, '..', 'src', '__fixtures__', 'personas');
const load = (name: string) =>
JSON.parse(readFileSync(resolve(fixturesRoot, name), 'utf8'));

const deployer = parsePersonaSpec(
load('integration-source-deployer.json'),
'documentation'
);
assert.deepEqual(deployer.integrations?.github.source, { kind: 'deployer_user' });

const workspace = parsePersonaSpec(
load('integration-source-workspace.json'),
'documentation'
);
assert.deepEqual(workspace.integrations?.slack.source, { kind: 'workspace' });

const sa = parsePersonaSpec(
load('integration-source-service-account.json'),
'documentation'
);
assert.deepEqual(sa.integrations?.github.source, {
kind: 'workspace_service_account',
name: 'release-bot'
});
});

test('parseIntegrations rejects extra name on deployer_user / workspace kinds', () => {
assert.throws(
() =>
parseIntegrations(
{ github: { source: { kind: 'deployer_user', name: 'release-bot' } } },
'integrations'
),
/integrations\.github\.source\.name is only allowed when kind="workspace_service_account"/
);
assert.throws(
() =>
parseIntegrations(
{ slack: { source: { kind: 'workspace', name: 'release-bot' } } },
'integrations'
),
/integrations\.slack\.source\.name is only allowed when kind="workspace_service_account"/
);
});

test('parseOnEvent enforces relative path with a supported extension', () => {
assert.equal(parseOnEvent('./agent.ts', 'onEvent'), './agent.ts');
assert.equal(parseOnEvent('handlers/main.mjs', 'onEvent'), 'handlers/main.mjs');
Expand Down
73 changes: 72 additions & 1 deletion packages/persona-kit/src/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
CodexSandboxMode,
Harness,
HarnessSettings,
IntegrationSource,
McpServerSpec,
PermissionMode,
PersonaInputSpec,
Expand Down Expand Up @@ -463,17 +464,87 @@ export function parseIntegrationTrigger(
};
}

/**
* Slug rules for `workspace_service_account.name`: kebab-case, ≤64 chars,
* lowercase ASCII letters/digits/hyphens, no leading/trailing/consecutive
* hyphens. Mirrors the convention used by other persona-kit identifiers.
*/
export const INTEGRATION_SOURCE_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const INTEGRATION_SOURCE_NAME_MAX = 64;

const INTEGRATION_SOURCE_KINDS = [
'deployer_user',
'workspace',
'workspace_service_account'
] as const;

type IntegrationSourceKind = (typeof INTEGRATION_SOURCE_KINDS)[number];

function isIntegrationSourceKind(value: unknown): value is IntegrationSourceKind {
return (
typeof value === 'string' &&
INTEGRATION_SOURCE_KINDS.includes(value as IntegrationSourceKind)
);
}

export function parseIntegrationSource(
value: unknown,
context: string
): IntegrationSource {
if (!isObject(value)) {
throw new Error(`${context} must be an object`);
}
const { kind, name } = value;
if (!isIntegrationSourceKind(kind)) {
throw new Error(
`${context}.kind must be one of: ${INTEGRATION_SOURCE_KINDS.join(', ')}`
);
}
if (kind === 'workspace_service_account') {
if (typeof name !== 'string' || !name) {
throw new Error(
`${context}.name must be a non-empty string when kind="workspace_service_account"`
);
}
if (name.length > INTEGRATION_SOURCE_NAME_MAX) {
throw new Error(
`${context}.name must be ≤${INTEGRATION_SOURCE_NAME_MAX} characters`
);
}
if (!INTEGRATION_SOURCE_NAME_RE.test(name)) {
throw new Error(
`${context}.name must be kebab-case matching ${INTEGRATION_SOURCE_NAME_RE.source}`
);
}
return { kind, name };
}
if (name !== undefined) {
throw new Error(
`${context}.name is only allowed when kind="workspace_service_account"`
);
}
return { kind };
}

export function parseIntegrationConfig(
value: unknown,
context: string
): PersonaIntegrationConfig {
if (!isObject(value)) {
throw new Error(`${context} must be an object`);
}
const { scope, triggers } = value;
const { source, scope, triggers } = value;

const out: PersonaIntegrationConfig = {};

// Default-inject `deployer_user` when the persona omits `source` so
// pre-discriminator personas keep parsing unchanged. The cloud-side
// resolver can then trust `source` is always present on parsed specs.
out.source =
source === undefined
? { kind: 'deployer_user' }
: parseIntegrationSource(source, `${context}.source`);

if (scope !== undefined) {
const parsedScope = parseStringMap(scope, `${context}.scope`);
if (parsedScope && Object.keys(parsedScope).length > 0) {
Expand Down
25 changes: 25 additions & 0 deletions packages/persona-kit/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,40 @@ export interface PersonaIntegrationTrigger {
where?: string;
}

/**
* Discriminator on how the cloud-side integration resolver should look up
* the connection backing a persona's declared integration:
*
* - `deployer_user` — resolve via `user_integrations` keyed by the deploying
* user (the default; matches today's behavior).
* - `workspace` — resolve via the workspace's default `workspace_integrations`
* row for this provider.
* - `workspace_service_account` — resolve via a named workspace service
* account (e.g. `release-bot`), letting one workspace expose multiple
* provider identities.
*
* The persona-kit only validates the shape; the cloud resolver enforces
* which sources are actually permitted at deploy time.
*/
export type IntegrationSource =
| { kind: 'deployer_user' }
| { kind: 'workspace' }
| { kind: 'workspace_service_account'; name: string };

/**
* Radio listener configuration for a RelayFile provider. The map key is
* the provider slug (`github`, `linear`, `slack`, `notion`, `jira`).
* `scope` is provider-specific filter metadata (e.g. `{ repo: "org/repo" }`
* for github, `{ database: "<id>" }` for notion). `triggers` are flat —
* all radio listener events for this provider fan into the same `onEvent`
* handler, which discriminates on `event.source` + `event.type`.
*
* `source` discriminates the cloud-side resolver between `user_integrations`
* and `workspace_integrations`; defaults to `{ kind: 'deployer_user' }` when
* omitted so existing personas keep their pre-discriminator behavior.
*/
export interface PersonaIntegrationConfig {
source?: IntegrationSource;
scope?: Record<string, string>;
triggers?: PersonaIntegrationTrigger[];
}
Expand Down
Loading