@agentprojectcontext/apx 1.59.0 → 1.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/agent/tools/handlers/_asana.js +34 -0
- package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
- package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
- package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
- package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
- package/src/core/agent/tools/names.js +10 -0
- package/src/core/agent/tools/registry.js +11 -0
- package/src/core/integrations/catalog.js +66 -0
- package/src/core/integrations/index.js +10 -0
- package/src/core/integrations/plugins/asana.js +231 -0
- package/src/core/integrations/sources.js +56 -0
- package/src/core/integrations/store.js +118 -0
- package/src/host/daemon/api/integrations.js +191 -0
- package/src/host/daemon/api.js +2 -0
- package/src/interfaces/web/dist/assets/{index-CnQb4N6C.js → index-DFNV6BWh.js} +193 -163
- package/src/interfaces/web/dist/assets/index-DFNV6BWh.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
- package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
- package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
- package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
- package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
- package/src/interfaces/web/src/lib/api.ts +1 -0
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +6 -2
- package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
- package/src/interfaces/web/dist/assets/index-CnQb4N6C.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-Dv3X-zpx.css +0 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// IntegrationStore: CRUD over one project's integrations.json, plus the
|
|
2
|
+
// cross-scope resolution used by agent tools. Pure filesystem + data logic —
|
|
3
|
+
// no Express, no plugin HTTP calls (those live in ./plugins/*). This keeps the
|
|
4
|
+
// storage layer testable in isolation (see tests/integrations.test.js).
|
|
5
|
+
import {
|
|
6
|
+
readIntegrations,
|
|
7
|
+
writeIntegrations,
|
|
8
|
+
defaultIntegrationsStorage,
|
|
9
|
+
} from "./sources.js";
|
|
10
|
+
|
|
11
|
+
// Keys inside `config` that hold secrets — never sent to the web UI in clear.
|
|
12
|
+
const SECRET_KEYS = new Set([
|
|
13
|
+
"personal_access_token",
|
|
14
|
+
"token",
|
|
15
|
+
"api_key",
|
|
16
|
+
"client_secret",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Return a shallow copy of a record with secret config values masked, safe to
|
|
20
|
+
// send to the browser. A present secret becomes `true` under `config.<key>_set`
|
|
21
|
+
// so the UI can show "configured" without leaking the value.
|
|
22
|
+
export function redactRecord(record) {
|
|
23
|
+
if (!record) return record;
|
|
24
|
+
const config = { ...(record.config || {}) };
|
|
25
|
+
for (const key of Object.keys(config)) {
|
|
26
|
+
if (SECRET_KEYS.has(key)) {
|
|
27
|
+
const hasValue = typeof config[key] === "string" && config[key].length > 0;
|
|
28
|
+
delete config[key];
|
|
29
|
+
config[`${key}_set`] = hasValue;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { ...record, config };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class IntegrationStore {
|
|
36
|
+
// `storagePath` is a project's ~/.apx/projects/<apxId>/ directory.
|
|
37
|
+
constructor(storagePath) {
|
|
38
|
+
if (!storagePath) throw new Error("IntegrationStore: storagePath required");
|
|
39
|
+
this.storagePath = storagePath;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
list() {
|
|
43
|
+
return readIntegrations(this.storagePath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(slug) {
|
|
47
|
+
return this.list().find((r) => r.slug === slug) || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Insert or merge a record by slug. `patch` is shallow-merged; `patch.config`
|
|
51
|
+
// is deep-merged one level so callers can update a single config key without
|
|
52
|
+
// clobbering the token. Returns the persisted record.
|
|
53
|
+
upsert(slug, patch = {}) {
|
|
54
|
+
const entries = this.list();
|
|
55
|
+
const idx = entries.findIndex((r) => r.slug === slug);
|
|
56
|
+
const now = new Date().toISOString();
|
|
57
|
+
if (idx === -1) {
|
|
58
|
+
const record = {
|
|
59
|
+
slug,
|
|
60
|
+
name: patch.name || slug,
|
|
61
|
+
type: patch.type || "custom",
|
|
62
|
+
description: patch.description || "",
|
|
63
|
+
source: patch.source || "builtin",
|
|
64
|
+
status: patch.status || "disconnected",
|
|
65
|
+
is_enabled: patch.is_enabled ?? false,
|
|
66
|
+
config: patch.config || {},
|
|
67
|
+
created_at: now,
|
|
68
|
+
updated_at: now,
|
|
69
|
+
};
|
|
70
|
+
entries.push(record);
|
|
71
|
+
writeIntegrations(this.storagePath, entries);
|
|
72
|
+
return record;
|
|
73
|
+
}
|
|
74
|
+
const prev = entries[idx];
|
|
75
|
+
const merged = {
|
|
76
|
+
...prev,
|
|
77
|
+
...patch,
|
|
78
|
+
config: { ...(prev.config || {}), ...(patch.config || {}) },
|
|
79
|
+
updated_at: now,
|
|
80
|
+
};
|
|
81
|
+
entries[idx] = merged;
|
|
82
|
+
writeIntegrations(this.storagePath, entries);
|
|
83
|
+
return merged;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
remove(slug) {
|
|
87
|
+
const entries = this.list();
|
|
88
|
+
const next = entries.filter((r) => r.slug !== slug);
|
|
89
|
+
if (next.length === entries.length) return false;
|
|
90
|
+
writeIntegrations(this.storagePath, next);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Resolve the effective integration for `slug` in a project, applying the
|
|
96
|
+
// project→default precedence: a project's OWN enabled record wins; otherwise
|
|
97
|
+
// the default project's enabled record is used. Returns { record, scope } or
|
|
98
|
+
// null when neither is usable. `defaultStorage` defaults to the default
|
|
99
|
+
// project's storage so callers only need the current project's storagePath.
|
|
100
|
+
export function resolveIntegration({
|
|
101
|
+
projectStorage,
|
|
102
|
+
slug,
|
|
103
|
+
defaultStorage = defaultIntegrationsStorage(),
|
|
104
|
+
requireEnabled = true,
|
|
105
|
+
}) {
|
|
106
|
+
const usable = (record) =>
|
|
107
|
+
!!record && (!requireEnabled || (record.is_enabled && record.status === "active"));
|
|
108
|
+
|
|
109
|
+
if (projectStorage) {
|
|
110
|
+
const own = new IntegrationStore(projectStorage).get(slug);
|
|
111
|
+
if (usable(own)) return { record: own, scope: "project", storagePath: projectStorage };
|
|
112
|
+
}
|
|
113
|
+
if (defaultStorage && defaultStorage !== projectStorage) {
|
|
114
|
+
const fallback = new IntegrationStore(defaultStorage).get(slug);
|
|
115
|
+
if (usable(fallback)) return { record: fallback, scope: "global", storagePath: defaultStorage };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Integration plugins per project. Companion to api/mcps.js: MCP servers are
|
|
2
|
+
// raw tool endpoints; integrations are higher-level plugins (Asana today) that
|
|
3
|
+
// own a credential + lifecycle and expose named tools to agents.
|
|
4
|
+
//
|
|
5
|
+
// Scoping (see core/integrations/store.js): every record lives in one project's
|
|
6
|
+
// integrations.json. `?scope=global` targets the DEFAULT project's store, so
|
|
7
|
+
// "global" integrations are literally the default project's — a project without
|
|
8
|
+
// its own record falls back to that one. This keeps "which Asana runs here?"
|
|
9
|
+
// unambiguous when you have both a base Asana and a per-project Asana.
|
|
10
|
+
//
|
|
11
|
+
// GET /projects/:pid/integrations?scope=project|global list stored (redacted)
|
|
12
|
+
// GET /projects/:pid/integrations/catalog roster + resolved status
|
|
13
|
+
// GET /projects/:pid/integrations/:slug?scope=… one plugin status
|
|
14
|
+
// POST /projects/:pid/integrations/:slug/configure?scope=… save credentials
|
|
15
|
+
// POST /projects/:pid/integrations/:slug/validate?scope=… verify against provider
|
|
16
|
+
// POST /projects/:pid/integrations/:slug/deactivate?scope=… disable
|
|
17
|
+
// POST /projects/:pid/integrations/:slug/action/:action?scope=… plugin read action
|
|
18
|
+
// DELETE /projects/:pid/integrations/:slug?scope=… remove
|
|
19
|
+
import {
|
|
20
|
+
IntegrationStore,
|
|
21
|
+
resolveIntegration,
|
|
22
|
+
redactRecord,
|
|
23
|
+
defaultIntegrationsStorage,
|
|
24
|
+
listCatalog,
|
|
25
|
+
getPluginService,
|
|
26
|
+
} from "#core/integrations/index.js";
|
|
27
|
+
|
|
28
|
+
function normalizeScope(raw) {
|
|
29
|
+
if (!raw) return "project";
|
|
30
|
+
const s = String(raw).toLowerCase();
|
|
31
|
+
if (s === "global" || s === "default") return "global";
|
|
32
|
+
if (s === "project" || s === "shared" || s === "runtime") return "project";
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Resolve the storagePath for the requested scope. `global` → default project
|
|
37
|
+
// store; `project` → the current project's store.
|
|
38
|
+
function storagePathForScope(scope, p, projects) {
|
|
39
|
+
if (scope === "global") {
|
|
40
|
+
const base = projects.get(0);
|
|
41
|
+
return base?.storagePath || defaultIntegrationsStorage();
|
|
42
|
+
}
|
|
43
|
+
return p.storagePath || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function register(app, { projects, project }) {
|
|
47
|
+
// List stored integrations in the chosen scope (secrets redacted).
|
|
48
|
+
app.get("/projects/:pid/integrations", (req, res) => {
|
|
49
|
+
const p = project(req, res);
|
|
50
|
+
if (!p) return;
|
|
51
|
+
const scope = normalizeScope(req.query?.scope);
|
|
52
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
53
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
54
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
55
|
+
const records = new IntegrationStore(storagePath).list().map(redactRecord);
|
|
56
|
+
res.json(records);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// The full plugin roster with each plugin's resolved status for this project
|
|
60
|
+
// (project record wins over the default/global one).
|
|
61
|
+
app.get("/projects/:pid/integrations/catalog", (req, res) => {
|
|
62
|
+
const p = project(req, res);
|
|
63
|
+
if (!p) return;
|
|
64
|
+
const catalog = listCatalog().map((entry) => {
|
|
65
|
+
const svc = getPluginService(entry.slug);
|
|
66
|
+
let status = { slug: entry.slug, status: "disconnected", is_enabled: false };
|
|
67
|
+
let scope = null;
|
|
68
|
+
if (svc) {
|
|
69
|
+
const resolved = resolveIntegration({
|
|
70
|
+
projectStorage: p.storagePath,
|
|
71
|
+
slug: entry.slug,
|
|
72
|
+
requireEnabled: false,
|
|
73
|
+
});
|
|
74
|
+
status = svc.status(resolved?.record || null);
|
|
75
|
+
scope = resolved?.scope || null;
|
|
76
|
+
}
|
|
77
|
+
return { ...entry, status, resolved_scope: scope };
|
|
78
|
+
});
|
|
79
|
+
res.json(catalog);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Status for a single plugin in the chosen scope.
|
|
83
|
+
app.get("/projects/:pid/integrations/:slug", (req, res) => {
|
|
84
|
+
const p = project(req, res);
|
|
85
|
+
if (!p) return;
|
|
86
|
+
const svc = getPluginService(req.params.slug);
|
|
87
|
+
if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
|
|
88
|
+
const scope = normalizeScope(req.query?.scope);
|
|
89
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
90
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
91
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
92
|
+
const record = new IntegrationStore(storagePath).get(req.params.slug);
|
|
93
|
+
res.json(svc.status(record));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Save credentials / config. Creates the record if missing.
|
|
97
|
+
app.post("/projects/:pid/integrations/:slug/configure", (req, res) => {
|
|
98
|
+
const p = project(req, res);
|
|
99
|
+
if (!p) return;
|
|
100
|
+
const svc = getPluginService(req.params.slug);
|
|
101
|
+
if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
|
|
102
|
+
const scope = normalizeScope(req.query?.scope);
|
|
103
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
104
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
105
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
106
|
+
const store = new IntegrationStore(storagePath);
|
|
107
|
+
try {
|
|
108
|
+
const { patch } = svc.configure(store.get(req.params.slug), req.body || {});
|
|
109
|
+
const record = store.upsert(req.params.slug, patch);
|
|
110
|
+
res.status(201).json(redactRecord(record));
|
|
111
|
+
} catch (e) {
|
|
112
|
+
res.status(400).json({ error: e.message });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Verify the stored credentials against the provider, then persist the result.
|
|
117
|
+
app.post("/projects/:pid/integrations/:slug/validate", async (req, res) => {
|
|
118
|
+
const p = project(req, res);
|
|
119
|
+
if (!p) return;
|
|
120
|
+
const svc = getPluginService(req.params.slug);
|
|
121
|
+
if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
|
|
122
|
+
const scope = normalizeScope(req.query?.scope);
|
|
123
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
124
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
125
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
126
|
+
const store = new IntegrationStore(storagePath);
|
|
127
|
+
const record = store.get(req.params.slug);
|
|
128
|
+
if (!record) return res.status(404).json({ error: "integration not configured" });
|
|
129
|
+
try {
|
|
130
|
+
const { patch, result } = await svc.validate(record);
|
|
131
|
+
store.upsert(req.params.slug, patch);
|
|
132
|
+
if (result && result.ok === false) return res.status(400).json(result);
|
|
133
|
+
res.json(result);
|
|
134
|
+
} catch (e) {
|
|
135
|
+
res.status(400).json({ error: e.message });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Disable a plugin without deleting its stored credentials.
|
|
140
|
+
app.post("/projects/:pid/integrations/:slug/deactivate", (req, res) => {
|
|
141
|
+
const p = project(req, res);
|
|
142
|
+
if (!p) return;
|
|
143
|
+
const svc = getPluginService(req.params.slug);
|
|
144
|
+
if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
|
|
145
|
+
const scope = normalizeScope(req.query?.scope);
|
|
146
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
147
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
148
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
149
|
+
const store = new IntegrationStore(storagePath);
|
|
150
|
+
if (!store.get(req.params.slug)) return res.status(404).json({ error: "integration not configured" });
|
|
151
|
+
const { patch } = svc.deactivate(store.get(req.params.slug));
|
|
152
|
+
const record = store.upsert(req.params.slug, patch);
|
|
153
|
+
res.json(svc.status(record));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Plugin-specific read action (e.g. Asana → list workspaces for the token).
|
|
157
|
+
app.post("/projects/:pid/integrations/:slug/action/:action", async (req, res) => {
|
|
158
|
+
const p = project(req, res);
|
|
159
|
+
if (!p) return;
|
|
160
|
+
const svc = getPluginService(req.params.slug);
|
|
161
|
+
if (!svc) return res.status(404).json({ error: `unknown plugin "${req.params.slug}"` });
|
|
162
|
+
const fn = svc.actions?.[req.params.action];
|
|
163
|
+
if (typeof fn !== "function") {
|
|
164
|
+
return res.status(404).json({ error: `unknown action "${req.params.action}"` });
|
|
165
|
+
}
|
|
166
|
+
const scope = normalizeScope(req.query?.scope);
|
|
167
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
168
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
169
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
170
|
+
const record = new IntegrationStore(storagePath).get(req.params.slug);
|
|
171
|
+
if (!record) return res.status(404).json({ error: "integration not configured" });
|
|
172
|
+
try {
|
|
173
|
+
res.json(await fn.call(svc.actions, record));
|
|
174
|
+
} catch (e) {
|
|
175
|
+
res.status(400).json({ error: e.message });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Remove a stored integration entirely.
|
|
180
|
+
app.delete("/projects/:pid/integrations/:slug", (req, res) => {
|
|
181
|
+
const p = project(req, res);
|
|
182
|
+
if (!p) return;
|
|
183
|
+
const scope = normalizeScope(req.query?.scope);
|
|
184
|
+
if (scope === null) return res.status(400).json({ error: `unknown scope "${req.query?.scope}"` });
|
|
185
|
+
const storagePath = storagePathForScope(scope, p, projects);
|
|
186
|
+
if (!storagePath) return res.status(400).json({ error: "project has no storage path" });
|
|
187
|
+
const removed = new IntegrationStore(storagePath).remove(req.params.slug);
|
|
188
|
+
if (!removed) return res.status(404).end();
|
|
189
|
+
res.status(204).end();
|
|
190
|
+
});
|
|
191
|
+
}
|
package/src/host/daemon/api.js
CHANGED
|
@@ -18,6 +18,7 @@ import { register as registerProjects } from "./api/projects.js";
|
|
|
18
18
|
import { register as registerAgents } from "./api/agents.js";
|
|
19
19
|
import { register as registerSessions } from "./api/sessions.js";
|
|
20
20
|
import { register as registerMcps } from "./api/mcps.js";
|
|
21
|
+
import { register as registerIntegrations } from "./api/integrations.js";
|
|
21
22
|
import { register as registerVars } from "./api/vars.js";
|
|
22
23
|
import { register as registerMessages } from "./api/messages.js";
|
|
23
24
|
import { register as registerTelegram } from "./api/telegram.js";
|
|
@@ -109,6 +110,7 @@ export function buildApi({
|
|
|
109
110
|
registerAgents(app, ctx);
|
|
110
111
|
registerSessions(app, ctx);
|
|
111
112
|
registerMcps(app, ctx);
|
|
113
|
+
registerIntegrations(app, ctx);
|
|
112
114
|
registerVars(app, ctx);
|
|
113
115
|
registerMessages(app, ctx);
|
|
114
116
|
registerEngines(app, ctx);
|