@pulsarinteractive/geniustasker 0.0.0-stage → 0.1.0-beta.10
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/CHANGELOG.md +80 -0
- package/CONTRIBUTING.md +72 -0
- package/LICENSE +21 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/README.md +76 -2
- package/SECURITY.md +14 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/geniustasker.mjs +6 -0
- package/docs/ARCHITECTURE.md +144 -0
- package/docs/CHATGPT.md +86 -0
- package/docs/CLI.md +444 -0
- package/docs/DOCUMENTATION_SYNC.md +144 -0
- package/docs/INTEGRATIONS.md +62 -0
- package/docs/LIMITS.md +62 -0
- package/docs/MEMORY.md +38 -0
- package/docs/OPENAI.md +21 -0
- package/docs/OPENAI_PUBLICATION.md +22 -0
- package/docs/PLANNING.md +79 -0
- package/docs/PROJECT_FILTERS.md +77 -0
- package/docs/RELEASING.md +23 -0
- package/docs/TESTING.md +52 -0
- package/docs/WORKFLOWS.md +37 -0
- package/examples/codex.toml +3 -0
- package/examples/mcp.json +8 -0
- package/mcp.json +10 -0
- package/package.json +72 -4
- package/plugin.json +24 -0
- package/remote/tool-catalog.json +1374 -0
- package/remote/workflows.json +67 -0
- package/skills/geniustasker/SKILL.md +148 -0
- package/skills/geniustasker/agents/openai.yaml +4 -0
- package/skills/geniustasker/references/collaboration.md +70 -0
- package/skills/geniustasker/references/incremental-reads.md +16 -0
- package/skills/geniustasker/references/profiles.md +22 -0
- package/skills/geniustasker/references/project-reads.md +43 -0
- package/skills/geniustasker-documentation/SKILL.md +39 -0
- package/skills/geniustasker-documentation/agents/openai.yaml +4 -0
- package/skills/geniustasker-documentation/references/structure.md +29 -0
- package/skills/geniustasker-graph/SKILL.md +31 -0
- package/skills/geniustasker-graph/agents/openai.yaml +4 -0
- package/skills/geniustasker-media/SKILL.md +33 -0
- package/skills/geniustasker-media/agents/openai.yaml +4 -0
- package/skills/geniustasker-memory/SKILL.md +120 -0
- package/skills/geniustasker-memory/agents/openai.yaml +4 -0
- package/skills/geniustasker-memory/references/knowledge-workflows.md +57 -0
- package/skills/geniustasker-planning/SKILL.md +28 -0
- package/skills/geniustasker-planning/agents/openai.yaml +4 -0
- package/src/changes.mjs +139 -0
- package/src/cli.mjs +511 -0
- package/src/client.mjs +264 -0
- package/src/command-catalog.mjs +569 -0
- package/src/commands.mjs +277 -0
- package/src/documentation-graph.mjs +251 -0
- package/src/documentation-store.mjs +589 -0
- package/src/documentation-sync.mjs +517 -0
- package/src/documentation.mjs +136 -0
- package/src/errors.mjs +65 -0
- package/src/knowledge.mjs +109 -0
- package/src/login.mjs +258 -0
- package/src/mcp.mjs +327 -0
- package/src/media.mjs +307 -0
- package/src/presentation.d.mts +1 -0
- package/src/presentation.mjs +12 -0
- package/src/profiles.mjs +154 -0
- package/src/read-cache.mjs +111 -0
- package/src/store.mjs +141 -0
- package/src/transport.mjs +201 -0
- package/src/workflows.mjs +9 -0
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { AgentError, fail } from './errors.mjs';
|
|
3
|
+
import catalog from './command-catalog.mjs';
|
|
4
|
+
|
|
5
|
+
const name = z
|
|
6
|
+
.string()
|
|
7
|
+
.trim()
|
|
8
|
+
.min(1)
|
|
9
|
+
.max(80)
|
|
10
|
+
// eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
|
|
11
|
+
.regex(/^[^\u0000-\u001f\u007f]+$/u);
|
|
12
|
+
const unique = (values) => new Set(values).size === values.length;
|
|
13
|
+
export const grantSchema = z
|
|
14
|
+
.object({
|
|
15
|
+
allResources: z.boolean(),
|
|
16
|
+
resourceIds: z
|
|
17
|
+
.array(z.string().min(1).max(256))
|
|
18
|
+
.max(catalog.grants.maxResources)
|
|
19
|
+
.refine(unique),
|
|
20
|
+
permissions: z
|
|
21
|
+
.array(z.enum(catalog.grants.permissions))
|
|
22
|
+
.max(catalog.grants.permissions.length)
|
|
23
|
+
.refine(unique),
|
|
24
|
+
})
|
|
25
|
+
.strict()
|
|
26
|
+
.refine((grant) => !grant.allResources || grant.resourceIds.length === 0);
|
|
27
|
+
export const profileCreateSchema = z.object({ id: z.uuid(), name, grant: grantSchema }).strict();
|
|
28
|
+
export const profileRestrictSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
profileId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/),
|
|
31
|
+
name,
|
|
32
|
+
expectedRevision: z.number().int().min(1),
|
|
33
|
+
grant: grantSchema,
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
export const profileSelectSchema = z
|
|
37
|
+
.object({ profileId: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/) })
|
|
38
|
+
.strict();
|
|
39
|
+
const choiceSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
name,
|
|
43
|
+
revision: z.number().int().min(1),
|
|
44
|
+
current: z.boolean(),
|
|
45
|
+
permissions: z.array(z.string()),
|
|
46
|
+
allResources: z.boolean(),
|
|
47
|
+
resourceCount: z.number().int().min(0),
|
|
48
|
+
})
|
|
49
|
+
.strict();
|
|
50
|
+
const choicesSchema = z
|
|
51
|
+
.object({
|
|
52
|
+
connectionId: z.string(),
|
|
53
|
+
currentProfileId: z.string(),
|
|
54
|
+
items: z.array(choiceSchema).max(catalog.grants.maxProfilesPerConnection),
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
const profileSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
id: z.string(),
|
|
60
|
+
connectionId: z.string(),
|
|
61
|
+
name,
|
|
62
|
+
revision: z.number().int().min(1),
|
|
63
|
+
grant: grantSchema,
|
|
64
|
+
disabledAt: z.number().nullable(),
|
|
65
|
+
})
|
|
66
|
+
.strict();
|
|
67
|
+
const input = (schema, value) => {
|
|
68
|
+
const result = schema.safeParse(value);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
fail('invalid_arguments', 'Use the declared profile fields, a valid name and a bounded grant.');
|
|
71
|
+
}
|
|
72
|
+
return result.data;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Profile management is separate from domain-operation journals: creation has
|
|
76
|
+
* an explicit UUID retained in the caller's file, restriction a live revision,
|
|
77
|
+
* and selection rotates the credential family under the credential-store lock. */
|
|
78
|
+
export class AgentProfiles {
|
|
79
|
+
constructor(client) {
|
|
80
|
+
this.client = client;
|
|
81
|
+
}
|
|
82
|
+
async list() {
|
|
83
|
+
const result = choicesSchema.safeParse(await this.client.get('profiles'));
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
fail('invalid_response', 'The profile list was invalid.');
|
|
86
|
+
}
|
|
87
|
+
return result.data;
|
|
88
|
+
}
|
|
89
|
+
async create(value) {
|
|
90
|
+
return this.write('profiles', 'POST', input(profileCreateSchema, value));
|
|
91
|
+
}
|
|
92
|
+
async restrict(value) {
|
|
93
|
+
return this.write('profile', 'PUT', input(profileRestrictSchema, value));
|
|
94
|
+
}
|
|
95
|
+
async write(resource, method, value) {
|
|
96
|
+
const session = await this.client.authorizedSession();
|
|
97
|
+
if (method === 'PUT' && value.profileId !== session.profile_id) {
|
|
98
|
+
fail('profile_selection_conflict', 'This draft belongs to another profile.', {
|
|
99
|
+
status: 409,
|
|
100
|
+
remediation: 'Read whoami and prepare a fresh restriction for the intended profile.',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const result = await this.client.transport.request(`/api/agents/v1/${resource}`, {
|
|
105
|
+
method,
|
|
106
|
+
token: session.access_token,
|
|
107
|
+
json: value,
|
|
108
|
+
});
|
|
109
|
+
const parsed = profileSchema.safeParse(result);
|
|
110
|
+
if (
|
|
111
|
+
!parsed.success ||
|
|
112
|
+
parsed.data.connectionId !== session.connection_id ||
|
|
113
|
+
parsed.data.id !== (method === 'POST' ? value.id : session.profile_id)
|
|
114
|
+
) {
|
|
115
|
+
fail('invalid_response', 'The profile change could not be confirmed.');
|
|
116
|
+
}
|
|
117
|
+
return parsed.data;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!(error instanceof AgentError)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
error.details.profileId = method === 'POST' ? value.id : session.profile_id;
|
|
123
|
+
error.details.execution ??= 'unknown';
|
|
124
|
+
error.remediation =
|
|
125
|
+
method === 'POST'
|
|
126
|
+
? 'List profiles and reconcile this profileId. Keep the same UUID on retry; do not create a replacement after an uncertain reply.'
|
|
127
|
+
: 'Read whoami to see the current profile and revision before preparing another restriction.';
|
|
128
|
+
throw error;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async select(value) {
|
|
132
|
+
const { profileId } = input(profileSelectSchema, value);
|
|
133
|
+
const choices = await this.list();
|
|
134
|
+
const source = choices.items.find((p) => p.id === choices.currentProfileId && p.current);
|
|
135
|
+
const target = choices.items.find((p) => p.id === profileId);
|
|
136
|
+
if (!source || !target) {
|
|
137
|
+
fail(
|
|
138
|
+
'profile_selection_denied',
|
|
139
|
+
'This profile is not available within the current connection and permissions.',
|
|
140
|
+
{
|
|
141
|
+
status: 403,
|
|
142
|
+
remediation: 'Run profiles list. An agent cannot switch back to broader permissions.',
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return this.client.selectProfile({
|
|
147
|
+
profileId,
|
|
148
|
+
expectedRevision: target.revision,
|
|
149
|
+
expectedSourceRevision: source.revision,
|
|
150
|
+
sourceProfileId: source.id,
|
|
151
|
+
connectionId: choices.connectionId,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { fail } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Bounded process-local LRU for conditional resource reads. Every access calls
|
|
5
|
+
* the server; TTL controls retention only, never permission freshness. Document
|
|
6
|
+
* data is neither persisted to disk nor served as an offline fallback.
|
|
7
|
+
*/
|
|
8
|
+
export class ConditionalReadCache {
|
|
9
|
+
constructor({
|
|
10
|
+
maxBytes = 8 * 1024 * 1024,
|
|
11
|
+
maxEntries = 64,
|
|
12
|
+
maxAgeMs = 900000,
|
|
13
|
+
now = Date.now,
|
|
14
|
+
} = {}) {
|
|
15
|
+
for (const value of [maxBytes, maxEntries, maxAgeMs]) {
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
17
|
+
throw new TypeError('Read cache limits must be positive integers.');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
this.maxBytes = maxBytes;
|
|
21
|
+
this.maxEntries = maxEntries;
|
|
22
|
+
this.maxAgeMs = maxAgeMs;
|
|
23
|
+
this.now = now;
|
|
24
|
+
this.entries = new Map();
|
|
25
|
+
this.pending = new Map();
|
|
26
|
+
this.bytes = 0;
|
|
27
|
+
this.generation = 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
clear() {
|
|
31
|
+
this.generation++;
|
|
32
|
+
this.entries.clear();
|
|
33
|
+
this.pending.clear();
|
|
34
|
+
this.bytes = 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
remove(key) {
|
|
38
|
+
const old = this.entries.get(key);
|
|
39
|
+
if (old) {
|
|
40
|
+
this.bytes -= old.bytes;
|
|
41
|
+
this.entries.delete(key);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async read(key, load) {
|
|
46
|
+
const existing = this.pending.get(key);
|
|
47
|
+
if (existing) {
|
|
48
|
+
return structuredClone(await existing);
|
|
49
|
+
}
|
|
50
|
+
const generation = this.generation;
|
|
51
|
+
for (const [entryKey, entry] of this.entries) {
|
|
52
|
+
if (this.now() - entry.at >= this.maxAgeMs) {
|
|
53
|
+
this.remove(entryKey);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const cached = this.entries.get(key);
|
|
57
|
+
const pending = (async () => {
|
|
58
|
+
const received = await load(cached?.value.etag);
|
|
59
|
+
if (generation !== this.generation) {
|
|
60
|
+
fail('read_context_changed', 'The read context changed while this request was running.', {
|
|
61
|
+
retryable: true,
|
|
62
|
+
remediation: 'Read again in the currently selected account and profile.',
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (!received || typeof received !== 'object' || Array.isArray(received)) {
|
|
66
|
+
fail('invalid_response', 'The resource response was invalid.');
|
|
67
|
+
}
|
|
68
|
+
let value = received;
|
|
69
|
+
if (received?.notModified === true) {
|
|
70
|
+
if (
|
|
71
|
+
!cached ||
|
|
72
|
+
received.etag !== cached.value.etag ||
|
|
73
|
+
received.scope !== cached.value.scope ||
|
|
74
|
+
received.collection !== cached.value.collection ||
|
|
75
|
+
received.epoch !== cached.value.epoch ||
|
|
76
|
+
received.checkpoint !== cached.value.checkpoint ||
|
|
77
|
+
!Array.isArray(received.items) ||
|
|
78
|
+
received.items.length !== 0
|
|
79
|
+
) {
|
|
80
|
+
fail('invalid_response', 'A conditional response did not match the cached resource.');
|
|
81
|
+
}
|
|
82
|
+
value = cached.value;
|
|
83
|
+
}
|
|
84
|
+
this.remove(key);
|
|
85
|
+
const size = Buffer.byteLength(JSON.stringify(value));
|
|
86
|
+
if (typeof value?.etag === 'string' && value.etag.length <= 8192 && size <= this.maxBytes) {
|
|
87
|
+
this.entries.set(key, { value: structuredClone(value), bytes: size, at: this.now() });
|
|
88
|
+
this.bytes += size;
|
|
89
|
+
while (this.entries.size > this.maxEntries || this.bytes > this.maxBytes) {
|
|
90
|
+
this.remove(this.entries.keys().next().value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return value;
|
|
94
|
+
})();
|
|
95
|
+
this.pending.set(key, pending);
|
|
96
|
+
try {
|
|
97
|
+
return structuredClone(await pending);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// A permission, authentication, network or schema failure can never turn
|
|
100
|
+
// into stale private success, or let another in-flight read refill it.
|
|
101
|
+
if (generation === this.generation) {
|
|
102
|
+
this.clear();
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
} finally {
|
|
106
|
+
if (this.pending.get(key) === pending) {
|
|
107
|
+
this.pending.delete(key);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/store.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { mkdir, lstat, open, rename, unlink, rmdir } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
7
|
+
import { fail } from './errors.mjs';
|
|
8
|
+
|
|
9
|
+
/** Dedicated directory only: do not point at a repository or a shared folder.
|
|
10
|
+
* File permissions protect against other OS users, not processes of this user.
|
|
11
|
+
* A hard crash deliberately leaves a lock/refresh marker rather than risking a
|
|
12
|
+
* second use of a rotated refresh token. Doctor explains recovery. */
|
|
13
|
+
export class CredentialStore {
|
|
14
|
+
constructor({
|
|
15
|
+
directory = join(homedir(), '.geniustasker'),
|
|
16
|
+
context = 'default',
|
|
17
|
+
lockTimeoutMs = 20000,
|
|
18
|
+
maxBytes = 16384,
|
|
19
|
+
} = {}) {
|
|
20
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/.test(context)) {
|
|
21
|
+
fail('invalid_arguments', 'Context names use 1–48 letters, numbers, underscores or hyphens.');
|
|
22
|
+
}
|
|
23
|
+
this.context = context;
|
|
24
|
+
this.maxBytes = maxBytes;
|
|
25
|
+
this.directory = resolve(directory);
|
|
26
|
+
this.path = join(this.directory, `${context}.json`);
|
|
27
|
+
this.lockPath = `${this.path}.lock`;
|
|
28
|
+
this.lockTimeoutMs = lockTimeoutMs;
|
|
29
|
+
}
|
|
30
|
+
async prepare() {
|
|
31
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
32
|
+
const stat = await lstat(this.directory);
|
|
33
|
+
if (
|
|
34
|
+
!stat.isDirectory() ||
|
|
35
|
+
stat.isSymbolicLink() ||
|
|
36
|
+
(process.getuid && stat.uid !== process.getuid()) ||
|
|
37
|
+
(process.platform !== 'win32' && stat.mode & 0o077)
|
|
38
|
+
) {
|
|
39
|
+
fail(
|
|
40
|
+
'unsafe_credential_store',
|
|
41
|
+
'The credential directory must be private and owned by you.',
|
|
42
|
+
{ remediation: 'Use a dedicated directory with permissions 0700.' },
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async read() {
|
|
47
|
+
await this.prepare();
|
|
48
|
+
let handle;
|
|
49
|
+
try {
|
|
50
|
+
handle = await open(this.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
fail('unsafe_credential_store', 'The credential file could not be opened safely.');
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const stat = await handle.stat();
|
|
59
|
+
if (
|
|
60
|
+
!stat.isFile() ||
|
|
61
|
+
stat.nlink !== 1 ||
|
|
62
|
+
stat.size > this.maxBytes ||
|
|
63
|
+
(process.platform !== 'win32' && stat.mode & 0o077) ||
|
|
64
|
+
(process.getuid && stat.uid !== process.getuid())
|
|
65
|
+
) {
|
|
66
|
+
fail('unsafe_credential_store', 'The credential file must be private and owned by you.');
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
70
|
+
} catch {
|
|
71
|
+
fail('invalid_credential_store', 'The credential file is damaged.', {
|
|
72
|
+
remediation:
|
|
73
|
+
'Revoke the session in Account → Agents, then remove this context and log in again.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
await handle.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async write(data) {
|
|
81
|
+
const temporary = `${this.path}.${randomUUID()}.tmp`;
|
|
82
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
83
|
+
try {
|
|
84
|
+
await handle.writeFile(JSON.stringify(data));
|
|
85
|
+
await handle.sync();
|
|
86
|
+
} finally {
|
|
87
|
+
await handle.close();
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
await rename(temporary, this.path);
|
|
91
|
+
if (process.platform !== 'win32') {
|
|
92
|
+
const directory = await open(this.directory, constants.O_RDONLY);
|
|
93
|
+
try {
|
|
94
|
+
await directory.sync();
|
|
95
|
+
} finally {
|
|
96
|
+
await directory.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await unlink(temporary).catch((error) => {
|
|
101
|
+
if (error.code !== 'ENOENT') {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
async remove() {
|
|
108
|
+
await unlink(this.path).catch((error) => {
|
|
109
|
+
if (error.code !== 'ENOENT') {
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
async locked(callback, signal) {
|
|
115
|
+
await this.prepare();
|
|
116
|
+
const deadline = Date.now() + this.lockTimeoutMs;
|
|
117
|
+
while (true) {
|
|
118
|
+
try {
|
|
119
|
+
await mkdir(this.lockPath, { mode: 0o700 });
|
|
120
|
+
break;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.code !== 'EEXIST') {
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
if (Date.now() >= deadline) {
|
|
126
|
+
fail('credential_store_busy', 'Another command owns this credential context.', {
|
|
127
|
+
retryable: true,
|
|
128
|
+
remediation:
|
|
129
|
+
'Wait for that command. After a crash, stop all clients using this context, then remove its empty .lock directory.',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
await delay(50, undefined, { signal });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
return await callback();
|
|
137
|
+
} finally {
|
|
138
|
+
await rmdir(this.lockPath);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { AgentError, fail } from './errors.mjs';
|
|
2
|
+
import catalog from './command-catalog.mjs';
|
|
3
|
+
|
|
4
|
+
export const ORIGINS = Object.freeze({
|
|
5
|
+
production: 'https://api-geniustasker.pulsarinteractive.cloud',
|
|
6
|
+
development: 'https://dev-api-geniustasker.pulsarinteractive.cloud',
|
|
7
|
+
});
|
|
8
|
+
export const CLIENT_ID = 'geniustasker-cli';
|
|
9
|
+
/** Resolve an approved API origin; loopback is available only by explicit opt-in. */
|
|
10
|
+
export function originOf(value = ORIGINS.production, allowLocal = false) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(value);
|
|
14
|
+
} catch {
|
|
15
|
+
fail('invalid_origin', 'An absolute API origin is required.');
|
|
16
|
+
}
|
|
17
|
+
if (
|
|
18
|
+
url.username ||
|
|
19
|
+
url.password ||
|
|
20
|
+
url.pathname !== '/' ||
|
|
21
|
+
url.search ||
|
|
22
|
+
url.hash ||
|
|
23
|
+
!(
|
|
24
|
+
Object.values(ORIGINS).includes(url.origin) ||
|
|
25
|
+
(allowLocal && url.protocol === 'http:' && ['127.0.0.1', '[::1]'].includes(url.hostname))
|
|
26
|
+
)
|
|
27
|
+
) {
|
|
28
|
+
fail(
|
|
29
|
+
'invalid_origin',
|
|
30
|
+
'Use a GeniusTasker API origin or an explicit loopback origin with --allow-local.',
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
return url.origin;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const identifier = (value) =>
|
|
37
|
+
typeof value === 'string' && /^[a-zA-Z0-9_.:-]{1,100}$/.test(value) ? value : undefined;
|
|
38
|
+
const boundedText = (value) =>
|
|
39
|
+
typeof value === 'string' &&
|
|
40
|
+
value.length <= 500 &&
|
|
41
|
+
// eslint-disable-next-line no-control-regex -- Reject control characters in untrusted input.
|
|
42
|
+
!/[\u0000-\u001f\u007f]/u.test(value) &&
|
|
43
|
+
!/gta1\.|wd[ar]_|sk-proj-/u.test(value)
|
|
44
|
+
? value
|
|
45
|
+
: undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Bounded HTTP transport for delegated endpoints, with no implicit retries.
|
|
48
|
+
* Call originOf before construction; inject fetch/signal for controlled tests
|
|
49
|
+
* and operation-level cancellation. Error serialization excludes raw responses.
|
|
50
|
+
*/
|
|
51
|
+
export class Transport {
|
|
52
|
+
constructor(origin, { fetchImpl = fetch, timeoutMs = 15000, signal } = {}) {
|
|
53
|
+
this.origin = origin;
|
|
54
|
+
this.fetch = fetchImpl;
|
|
55
|
+
this.timeoutMs = timeoutMs;
|
|
56
|
+
this.signal = signal;
|
|
57
|
+
}
|
|
58
|
+
async request(path, { method = 'GET', form, json, token, query, bytes, binary = false } = {}) {
|
|
59
|
+
if (
|
|
60
|
+
!(path.startsWith('/api/agents/') || path === '/.well-known/oauth-authorization-server') ||
|
|
61
|
+
path.startsWith('//')
|
|
62
|
+
) {
|
|
63
|
+
fail('invalid_arguments', 'Only delegated endpoints are available.');
|
|
64
|
+
}
|
|
65
|
+
const url = new URL(path, this.origin);
|
|
66
|
+
if (
|
|
67
|
+
url.origin !== this.origin ||
|
|
68
|
+
!(
|
|
69
|
+
url.pathname.startsWith('/api/agents/') ||
|
|
70
|
+
url.pathname === '/.well-known/oauth-authorization-server'
|
|
71
|
+
)
|
|
72
|
+
) {
|
|
73
|
+
fail('invalid_origin', 'Cross-origin or non-delegated requests are refused.');
|
|
74
|
+
}
|
|
75
|
+
if (query) {
|
|
76
|
+
for (const [key, value] of Object.entries(query)) {
|
|
77
|
+
if (value !== undefined) {
|
|
78
|
+
url.searchParams.set(key, String(value));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
(bytes !== undefined || binary) &&
|
|
84
|
+
(url.pathname !== '/api/agents/v1/media/content' ||
|
|
85
|
+
(bytes !== undefined && (!Buffer.isBuffer(bytes) || bytes.length > 15000000)) ||
|
|
86
|
+
(bytes !== undefined && (json !== undefined || form)))
|
|
87
|
+
) {
|
|
88
|
+
fail('invalid_arguments', 'Binary transfer requires the bounded delegated media endpoint.');
|
|
89
|
+
}
|
|
90
|
+
if (form && json !== undefined) {
|
|
91
|
+
fail('invalid_arguments', 'Choose one request body format.');
|
|
92
|
+
}
|
|
93
|
+
const jsonBody = json === undefined ? undefined : JSON.stringify(json);
|
|
94
|
+
if (jsonBody && Buffer.byteLength(jsonBody) > catalog.maxCommandBytes) {
|
|
95
|
+
fail(
|
|
96
|
+
'invalid_arguments',
|
|
97
|
+
`Command exceeds the ${catalog.maxCommandBytes} byte request limit.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
let response;
|
|
101
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
102
|
+
const signal = this.signal ? AbortSignal.any([timeout, this.signal]) : timeout;
|
|
103
|
+
try {
|
|
104
|
+
response = await this.fetch(url, {
|
|
105
|
+
method,
|
|
106
|
+
redirect: 'error',
|
|
107
|
+
signal,
|
|
108
|
+
headers: {
|
|
109
|
+
Accept: binary ? 'application/octet-stream' : 'application/json',
|
|
110
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
111
|
+
...(bytes !== undefined
|
|
112
|
+
? { 'Content-Type': 'application/octet-stream', 'Content-Length': String(bytes.length) }
|
|
113
|
+
: form
|
|
114
|
+
? { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
115
|
+
: jsonBody
|
|
116
|
+
? { 'Content-Type': 'application/json' }
|
|
117
|
+
: {}),
|
|
118
|
+
},
|
|
119
|
+
...(bytes !== undefined
|
|
120
|
+
? { body: bytes }
|
|
121
|
+
: form
|
|
122
|
+
? { body: new URLSearchParams(form) }
|
|
123
|
+
: jsonBody
|
|
124
|
+
? { body: jsonBody }
|
|
125
|
+
: {}),
|
|
126
|
+
});
|
|
127
|
+
} catch {
|
|
128
|
+
throw new AgentError('network_unavailable', 'The API did not return a usable response.', {
|
|
129
|
+
retryable: true,
|
|
130
|
+
remediation:
|
|
131
|
+
'Check connectivity. Authentication exchanges are never retried automatically.',
|
|
132
|
+
execution: method === 'GET' ? 'not_applicable' : 'unknown',
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const chunks = [];
|
|
136
|
+
let size = 0;
|
|
137
|
+
try {
|
|
138
|
+
if (response.body) {
|
|
139
|
+
for await (const chunk of response.body) {
|
|
140
|
+
size += chunk.length;
|
|
141
|
+
if (size > (binary && response.ok ? 15000000 : 262144)) {
|
|
142
|
+
fail('invalid_response', 'The API response exceeded the size limit.');
|
|
143
|
+
}
|
|
144
|
+
chunks.push(chunk);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof AgentError) {
|
|
149
|
+
throw error;
|
|
150
|
+
}
|
|
151
|
+
fail('network_unavailable', 'The API response was interrupted.', {
|
|
152
|
+
retryable: true,
|
|
153
|
+
execution: method === 'GET' ? 'not_applicable' : 'unknown',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (binary && response.ok) {
|
|
157
|
+
return Buffer.concat(chunks);
|
|
158
|
+
}
|
|
159
|
+
let data;
|
|
160
|
+
try {
|
|
161
|
+
data = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
|
|
162
|
+
} catch {
|
|
163
|
+
fail('invalid_response', 'The API returned an unexpected format.', {
|
|
164
|
+
status: response.status,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const e = data?.error;
|
|
169
|
+
const code = identifier(typeof e === 'string' ? e : e?.code) ?? 'api_error';
|
|
170
|
+
const retry = Number(response.headers.get('Retry-After') ?? e?.retryAfterSeconds);
|
|
171
|
+
throw new AgentError(code, boundedText(e?.message) ?? 'The API refused the request.', {
|
|
172
|
+
status: response.status,
|
|
173
|
+
retryable:
|
|
174
|
+
typeof e?.retryable === 'boolean'
|
|
175
|
+
? e.retryable
|
|
176
|
+
: response.status === 429 || response.status === 503,
|
|
177
|
+
remediation:
|
|
178
|
+
boundedText(e?.remediation) ??
|
|
179
|
+
(response.status === 401
|
|
180
|
+
? 'Run auth login again; check the connection in Account → Agents.'
|
|
181
|
+
: 'Check permissions, availability and command help.'),
|
|
182
|
+
...(identifier(e?.requestId) ? { requestId: e.requestId } : {}),
|
|
183
|
+
...(Number.isFinite(retry) && retry >= 0 && retry <= 86400
|
|
184
|
+
? { retryAfterSeconds: retry }
|
|
185
|
+
: {}),
|
|
186
|
+
...(Number.isSafeInteger(data?.interval) && data.interval > 0
|
|
187
|
+
? { interval: data.interval }
|
|
188
|
+
: {}),
|
|
189
|
+
...(identifier(e?.execution) ? { execution: e.execution } : {}),
|
|
190
|
+
...(Number.isSafeInteger(e?.resetAt) && e.resetAt > 0 ? { resetAt: e.resetAt } : {}),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return data;
|
|
194
|
+
}
|
|
195
|
+
oauth(endpoint, form) {
|
|
196
|
+
return this.request(`/api/agents/oauth/${endpoint}`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
form: { client_id: CLIENT_ID, ...form },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import guides from '../remote/workflows.json' with { type: 'json' };
|
|
3
|
+
|
|
4
|
+
/** Reviewed static guidance, shared with the hosted connector. No account data. */
|
|
5
|
+
export const workflowSchema = z.object({ topic: z.enum(Object.keys(guides.topics)) }).strict();
|
|
6
|
+
export function workflow(input) {
|
|
7
|
+
const { topic } = workflowSchema.parse(input);
|
|
8
|
+
return { version: guides.version, topic, ...guides.topics[topic] };
|
|
9
|
+
}
|