@rubytech/create-realagent 1.0.824 → 1.0.826
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/payload/platform/lib/task-secrets/dist/index.d.ts +40 -0
- package/payload/platform/lib/task-secrets/dist/index.d.ts.map +1 -0
- package/payload/platform/lib/task-secrets/dist/index.js +44 -0
- package/payload/platform/lib/task-secrets/dist/index.js.map +1 -0
- package/payload/platform/lib/task-secrets/src/__tests__/redact-secrets.test.ts +127 -0
- package/payload/platform/lib/task-secrets/src/index.ts +77 -0
- package/payload/platform/lib/task-secrets/tsconfig.json +9 -0
- package/payload/platform/lib/task-secrets/vitest.config.ts +9 -0
- package/payload/platform/neo4j/schema.cypher +11 -0
- package/payload/platform/package.json +2 -2
- package/payload/platform/plugins/admin/skills/business-profile/SKILL.md +2 -2
- package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +12 -9
- package/payload/platform/plugins/admin/skills/plugin-management/SKILL.md +4 -4
- package/payload/platform/plugins/admin/skills/public-agent-manager/SKILL.md +2 -2
- package/payload/platform/plugins/admin/skills/stream-log-review/SKILL.md +6 -6
- package/payload/platform/plugins/admin/skills/unzip-attachment/references/safety.md +1 -1
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +3 -3
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +4 -4
- package/payload/platform/plugins/docs/references/cloudflare.md +1 -1
- package/payload/platform/plugins/docs/references/internals.md +2 -2
- package/payload/platform/plugins/docs/references/memory-guide.md +1 -1
- package/payload/platform/plugins/docs/references/troubleshooting.md +1 -1
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/SKILL.md +2 -2
- package/payload/platform/plugins/linkedin-import/skills/linkedin-import/references/connections.md +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.d.ts.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js +87 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/__tests__/profile-update-not-applicable.test.js.map +1 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts +2 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js +100 -8
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-read.js.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts +19 -0
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.d.ts.map +1 -1
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js +60 -27
- package/payload/platform/plugins/memory/mcp/dist/tools/profile-update.js.map +1 -1
- package/payload/platform/plugins/memory/references/graph-primitives.md +5 -5
- package/payload/platform/plugins/memory/references/schema-base.md +1 -1
- package/payload/platform/plugins/memory/skills/document-ingest/SKILL.md +6 -6
- package/payload/platform/plugins/tasks/PLUGIN.md +1 -1
- package/payload/platform/plugins/tasks/mcp/dist/index.js +11 -2
- package/payload/platform/plugins/tasks/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts +19 -2
- package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.d.ts.map +1 -1
- package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js +17 -1
- package/payload/platform/plugins/tasks/mcp/dist/tools/task-create.js.map +1 -1
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/SKILL.md +9 -9
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import/references/export-parse.md +2 -2
- package/payload/platform/plugins/whatsapp-import/skills/whatsapp-import-enrich/SKILL.md +8 -8
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/specialists/agents/database-operator.md +10 -10
- package/payload/server/chunk-AEHTLEC3.js +2302 -0
- package/payload/server/chunk-F5QBVHLS.js +1116 -0
- package/payload/server/chunk-HAXOJNAM.js +10079 -0
- package/payload/server/chunk-TDTQEKNP.js +593 -0
- package/payload/server/chunk-ZTBTX3IO.js +642 -0
- package/payload/server/client-pool-FXCFSUXR.js +32 -0
- package/payload/server/cloudflare-task-tracker-3WV7DZKQ.js +17 -0
- package/payload/server/cloudflare-task-tracker-BAMJY4MH.js +17 -0
- package/payload/server/maxy-edge.js +3 -3
- package/payload/server/neo4j-migrations-5FVPIWDW.js +428 -0
- package/payload/server/server.js +20 -14
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
2
|
+
[key: string]: JsonValue;
|
|
3
|
+
};
|
|
4
|
+
export interface FormSchema {
|
|
5
|
+
/**
|
|
6
|
+
* Field names whose values are secret and must never land on a Task node.
|
|
7
|
+
* Declared at the form definition (e.g. `cloudflare-setup-form`'s schema in
|
|
8
|
+
* `cloudflare-setup-types.ts`), not at the writer. Writers pass the form
|
|
9
|
+
* schema through unmodified — the contract is "secrets are declared once
|
|
10
|
+
* where the form is defined, redaction is enforced once at write time."
|
|
11
|
+
*/
|
|
12
|
+
secretFields?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface RedactResult {
|
|
15
|
+
/**
|
|
16
|
+
* Payload with every `secretFields` entry removed. Non-secret keys keep
|
|
17
|
+
* their values intact (including null, false, 0, empty string). Keys not
|
|
18
|
+
* mentioned in `secretFields` always pass through — secrets are an
|
|
19
|
+
* allow-list of names to drop, not the entire universe.
|
|
20
|
+
*/
|
|
21
|
+
redacted: Record<string, JsonValue>;
|
|
22
|
+
/** Number of fields that were stripped. Callers log this when > 0. */
|
|
23
|
+
droppedFields: number;
|
|
24
|
+
/** Names of fields that were stripped, sorted for stable log lines. */
|
|
25
|
+
droppedFieldNames: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Strip every secret-tagged field from `payload` per `schema`. Pure: no IO,
|
|
29
|
+
* no logging, no thrown errors. Caller is responsible for the
|
|
30
|
+
* `[task] redacted ...` log line when `droppedFields > 0`.
|
|
31
|
+
*
|
|
32
|
+
* Edge-case behaviour:
|
|
33
|
+
* - `payload` undefined / null → empty result, droppedFields=0.
|
|
34
|
+
* - `schema` undefined / no `secretFields` → every key passes through.
|
|
35
|
+
* - A secret field whose key is absent from `payload` → no-op (not counted).
|
|
36
|
+
* - Non-string values on a secret field → key still dropped, value never
|
|
37
|
+
* inspected. Redaction is by KEY, not by value-shape heuristic.
|
|
38
|
+
*/
|
|
39
|
+
export declare function redactSecrets(payload: Record<string, JsonValue> | null | undefined, schema: FormSchema | null | undefined): RedactResult;
|
|
40
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,SAAS,GACjB,MAAM,GACN,MAAM,GACN,OAAO,GACP,IAAI,GACJ,SAAS,EAAE,GACX;IAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC;AAEjC,MAAM,WAAW,UAAU;IACzB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IAC3B;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACpC,sEAAsE;IACtE,aAAa,EAAE,MAAM,CAAC;IACtB,uEAAuE;IACvE,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,IAAI,GAAG,SAAS,EACrD,MAAM,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GACpC,YAAY,CAgBd"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Task 890 — outcome contract for audit Task input persistence.
|
|
3
|
+
//
|
|
4
|
+
// Doctrine: every audit Task records enough operator-meaningful information to
|
|
5
|
+
// explain what happened, AND no audit Task contains secret material. Mechanism
|
|
6
|
+
// is centralised here, not at the per-kind writer: form/action input
|
|
7
|
+
// definitions tag secret fields at the source of truth, and writers strip
|
|
8
|
+
// those fields via `redactSecrets` before persisting `inputs.<field>` props
|
|
9
|
+
// onto the Task node.
|
|
10
|
+
//
|
|
11
|
+
// Single file by design — no per-kind branching, no factory. A future
|
|
12
|
+
// schema shape change extends `FormSchema` here; the contract stays one place.
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.redactSecrets = redactSecrets;
|
|
15
|
+
/**
|
|
16
|
+
* Strip every secret-tagged field from `payload` per `schema`. Pure: no IO,
|
|
17
|
+
* no logging, no thrown errors. Caller is responsible for the
|
|
18
|
+
* `[task] redacted ...` log line when `droppedFields > 0`.
|
|
19
|
+
*
|
|
20
|
+
* Edge-case behaviour:
|
|
21
|
+
* - `payload` undefined / null → empty result, droppedFields=0.
|
|
22
|
+
* - `schema` undefined / no `secretFields` → every key passes through.
|
|
23
|
+
* - A secret field whose key is absent from `payload` → no-op (not counted).
|
|
24
|
+
* - Non-string values on a secret field → key still dropped, value never
|
|
25
|
+
* inspected. Redaction is by KEY, not by value-shape heuristic.
|
|
26
|
+
*/
|
|
27
|
+
function redactSecrets(payload, schema) {
|
|
28
|
+
if (!payload) {
|
|
29
|
+
return { redacted: {}, droppedFields: 0, droppedFieldNames: [] };
|
|
30
|
+
}
|
|
31
|
+
const secrets = new Set(schema?.secretFields ?? []);
|
|
32
|
+
const redacted = {};
|
|
33
|
+
const dropped = [];
|
|
34
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
35
|
+
if (secrets.has(key)) {
|
|
36
|
+
dropped.push(key);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
redacted[key] = value;
|
|
40
|
+
}
|
|
41
|
+
dropped.sort();
|
|
42
|
+
return { redacted, droppedFields: dropped.length, droppedFieldNames: dropped };
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA,gEAAgE;AAChE,EAAE;AACF,+EAA+E;AAC/E,+EAA+E;AAC/E,qEAAqE;AACrE,0EAA0E;AAC1E,4EAA4E;AAC5E,sBAAsB;AACtB,EAAE;AACF,sEAAsE;AACtE,+EAA+E;;AA+C/E,sCAmBC;AA/BD;;;;;;;;;;;GAWG;AACH,SAAgB,aAAa,CAC3B,OAAqD,EACrD,MAAqC;IAErC,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,aAAa,EAAE,CAAC,EAAE,iBAAiB,EAAE,EAAE,EAAE,CAAC;IACnE,CAAC;IACD,MAAM,OAAO,GAAG,IAAI,GAAG,CAAC,MAAM,EAAE,YAAY,IAAI,EAAE,CAAC,CAAC;IACpD,MAAM,QAAQ,GAA8B,EAAE,CAAC;IAC/C,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAI,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YACrB,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAClB,SAAS;QACX,CAAC;QACD,QAAQ,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IACxB,CAAC;IACD,OAAO,CAAC,IAAI,EAAE,CAAC;IACf,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,OAAO,CAAC,MAAM,EAAE,iBAAiB,EAAE,OAAO,EAAE,CAAC;AACjF,CAAC"}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Task 890 — no-secret-leak contract test.
|
|
2
|
+
//
|
|
3
|
+
// Verifies that `redactSecrets` strips every key declared as a secret in the
|
|
4
|
+
// form schema, and is meaningful (not a no-op) — removing the `secret` tag
|
|
5
|
+
// from a field MUST cause a deliberately-secret value to leak into the result,
|
|
6
|
+
// proving the test would catch a regression.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'vitest';
|
|
9
|
+
import { redactSecrets, type FormSchema } from '../index.js';
|
|
10
|
+
|
|
11
|
+
const SECRET_VALUE = 'hunter2';
|
|
12
|
+
const SECRET_REGEX = /password|token|api[_-]?key|secret|credential|bearer/i;
|
|
13
|
+
|
|
14
|
+
describe('redactSecrets', () => {
|
|
15
|
+
it('drops every key listed in secretFields', () => {
|
|
16
|
+
const schema: FormSchema = { secretFields: ['password', 'apiKey'] };
|
|
17
|
+
const payload = {
|
|
18
|
+
adminLabel: 'maxy-test',
|
|
19
|
+
adminDomain: 'maxy.bot',
|
|
20
|
+
password: SECRET_VALUE,
|
|
21
|
+
apiKey: 'sk-abc',
|
|
22
|
+
};
|
|
23
|
+
const { redacted, droppedFields, droppedFieldNames } = redactSecrets(payload, schema);
|
|
24
|
+
|
|
25
|
+
expect(redacted).toEqual({ adminLabel: 'maxy-test', adminDomain: 'maxy.bot' });
|
|
26
|
+
expect(droppedFields).toBe(2);
|
|
27
|
+
expect(droppedFieldNames).toEqual(['apiKey', 'password']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('passes every key through when schema has no secretFields', () => {
|
|
31
|
+
const payload = { mode: 'personal', emailProvided: true };
|
|
32
|
+
const { redacted, droppedFields } = redactSecrets(payload, {});
|
|
33
|
+
expect(redacted).toEqual(payload);
|
|
34
|
+
expect(droppedFields).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('passes every key through when schema is undefined', () => {
|
|
38
|
+
const payload = { mode: 'personal' };
|
|
39
|
+
const { redacted, droppedFields } = redactSecrets(payload, undefined);
|
|
40
|
+
expect(redacted).toEqual(payload);
|
|
41
|
+
expect(droppedFields).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns empty result for null/undefined payload', () => {
|
|
45
|
+
expect(redactSecrets(null, { secretFields: ['x'] })).toEqual({
|
|
46
|
+
redacted: {},
|
|
47
|
+
droppedFields: 0,
|
|
48
|
+
droppedFieldNames: [],
|
|
49
|
+
});
|
|
50
|
+
expect(redactSecrets(undefined, undefined)).toEqual({
|
|
51
|
+
redacted: {},
|
|
52
|
+
droppedFields: 0,
|
|
53
|
+
droppedFieldNames: [],
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves falsy non-secret values (false, 0, empty string, null)', () => {
|
|
58
|
+
const payload = { a: false, b: 0, c: '', d: null, secret: 's' };
|
|
59
|
+
const { redacted } = redactSecrets(payload, { secretFields: ['secret'] });
|
|
60
|
+
expect(redacted).toEqual({ a: false, b: 0, c: '', d: null });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not count secret keys absent from payload', () => {
|
|
64
|
+
const schema: FormSchema = { secretFields: ['password', 'missingField'] };
|
|
65
|
+
const { droppedFields, droppedFieldNames } = redactSecrets({ password: SECRET_VALUE }, schema);
|
|
66
|
+
expect(droppedFields).toBe(1);
|
|
67
|
+
expect(droppedFieldNames).toEqual(['password']);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('drops a secret key regardless of its value shape', () => {
|
|
71
|
+
const schema: FormSchema = { secretFields: ['password'] };
|
|
72
|
+
const cases: Array<{ password: unknown }> = [
|
|
73
|
+
{ password: 'string-value' },
|
|
74
|
+
{ password: 42 },
|
|
75
|
+
{ password: null },
|
|
76
|
+
{ password: { nested: 'object' } },
|
|
77
|
+
{ password: ['array', 'of', 'strings'] },
|
|
78
|
+
];
|
|
79
|
+
for (const c of cases) {
|
|
80
|
+
const { redacted } = redactSecrets(c as Record<string, never>, schema);
|
|
81
|
+
expect(redacted).not.toHaveProperty('password');
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('CONTRACT: no recorded property value contains a secret regex match (cloudflare-setup-form)', () => {
|
|
86
|
+
// Synthetic cloudflare submission shape — mirrors CloudflareSetupRequest
|
|
87
|
+
// including both the operator-supplied secret (password) and the
|
|
88
|
+
// wire-protocol envelope (session_key, messageId) the schema also drops.
|
|
89
|
+
const cloudflareSchema: FormSchema = { secretFields: ['password', 'session_key', 'messageId'] };
|
|
90
|
+
const payload = {
|
|
91
|
+
adminLabel: 'maxy-test',
|
|
92
|
+
adminDomain: 'maxy.bot',
|
|
93
|
+
publicLabel: 'public',
|
|
94
|
+
publicDomain: 'maxy.bot',
|
|
95
|
+
apex: 'maxy.bot',
|
|
96
|
+
password: SECRET_VALUE,
|
|
97
|
+
tunnelName: 'maxy-test',
|
|
98
|
+
session_key: 'sk-conversation-correlation-12345678',
|
|
99
|
+
messageId: '11111111-2222-3333-4444-555555555555',
|
|
100
|
+
};
|
|
101
|
+
const { redacted } = redactSecrets(payload, cloudflareSchema);
|
|
102
|
+
|
|
103
|
+
// Scan every recorded value for any known-secret regex match.
|
|
104
|
+
for (const [key, value] of Object.entries(redacted)) {
|
|
105
|
+
const stringified = JSON.stringify(value);
|
|
106
|
+
expect(stringified, `field ${key} carried a secret-shaped value`).not.toMatch(SECRET_REGEX);
|
|
107
|
+
expect(stringified, `field ${key} carried the secret literal`).not.toContain(SECRET_VALUE);
|
|
108
|
+
}
|
|
109
|
+
// Wire-envelope fields must also be absent from recorded props.
|
|
110
|
+
expect(redacted).not.toHaveProperty('session_key');
|
|
111
|
+
expect(redacted).not.toHaveProperty('messageId');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('CONTRACT (falsifiability): removing the secret tag causes the password to leak — proves the test is meaningful', () => {
|
|
115
|
+
// This test is the canary that proves the no-secret-leak guarantee comes
|
|
116
|
+
// from the schema, not from coincidence. With `secretFields` empty (the
|
|
117
|
+
// schema-author forgot to tag `password`), the value MUST leak into the
|
|
118
|
+
// recorded payload — otherwise the contract test above would still pass
|
|
119
|
+
// even with a broken schema, and would catch nothing.
|
|
120
|
+
const brokenSchema: FormSchema = { secretFields: [] };
|
|
121
|
+
const payload = { adminLabel: 'maxy-test', password: SECRET_VALUE };
|
|
122
|
+
const { redacted } = redactSecrets(payload, brokenSchema);
|
|
123
|
+
|
|
124
|
+
expect(redacted.password).toBe(SECRET_VALUE);
|
|
125
|
+
expect(JSON.stringify(redacted)).toContain(SECRET_VALUE);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Task 890 — outcome contract for audit Task input persistence.
|
|
2
|
+
//
|
|
3
|
+
// Doctrine: every audit Task records enough operator-meaningful information to
|
|
4
|
+
// explain what happened, AND no audit Task contains secret material. Mechanism
|
|
5
|
+
// is centralised here, not at the per-kind writer: form/action input
|
|
6
|
+
// definitions tag secret fields at the source of truth, and writers strip
|
|
7
|
+
// those fields via `redactSecrets` before persisting `inputs.<field>` props
|
|
8
|
+
// onto the Task node.
|
|
9
|
+
//
|
|
10
|
+
// Single file by design — no per-kind branching, no factory. A future
|
|
11
|
+
// schema shape change extends `FormSchema` here; the contract stays one place.
|
|
12
|
+
|
|
13
|
+
export type JsonValue =
|
|
14
|
+
| string
|
|
15
|
+
| number
|
|
16
|
+
| boolean
|
|
17
|
+
| null
|
|
18
|
+
| JsonValue[]
|
|
19
|
+
| { [key: string]: JsonValue };
|
|
20
|
+
|
|
21
|
+
export interface FormSchema {
|
|
22
|
+
/**
|
|
23
|
+
* Field names whose values are secret and must never land on a Task node.
|
|
24
|
+
* Declared at the form definition (e.g. `cloudflare-setup-form`'s schema in
|
|
25
|
+
* `cloudflare-setup-types.ts`), not at the writer. Writers pass the form
|
|
26
|
+
* schema through unmodified — the contract is "secrets are declared once
|
|
27
|
+
* where the form is defined, redaction is enforced once at write time."
|
|
28
|
+
*/
|
|
29
|
+
secretFields?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RedactResult {
|
|
33
|
+
/**
|
|
34
|
+
* Payload with every `secretFields` entry removed. Non-secret keys keep
|
|
35
|
+
* their values intact (including null, false, 0, empty string). Keys not
|
|
36
|
+
* mentioned in `secretFields` always pass through — secrets are an
|
|
37
|
+
* allow-list of names to drop, not the entire universe.
|
|
38
|
+
*/
|
|
39
|
+
redacted: Record<string, JsonValue>;
|
|
40
|
+
/** Number of fields that were stripped. Callers log this when > 0. */
|
|
41
|
+
droppedFields: number;
|
|
42
|
+
/** Names of fields that were stripped, sorted for stable log lines. */
|
|
43
|
+
droppedFieldNames: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Strip every secret-tagged field from `payload` per `schema`. Pure: no IO,
|
|
48
|
+
* no logging, no thrown errors. Caller is responsible for the
|
|
49
|
+
* `[task] redacted ...` log line when `droppedFields > 0`.
|
|
50
|
+
*
|
|
51
|
+
* Edge-case behaviour:
|
|
52
|
+
* - `payload` undefined / null → empty result, droppedFields=0.
|
|
53
|
+
* - `schema` undefined / no `secretFields` → every key passes through.
|
|
54
|
+
* - A secret field whose key is absent from `payload` → no-op (not counted).
|
|
55
|
+
* - Non-string values on a secret field → key still dropped, value never
|
|
56
|
+
* inspected. Redaction is by KEY, not by value-shape heuristic.
|
|
57
|
+
*/
|
|
58
|
+
export function redactSecrets(
|
|
59
|
+
payload: Record<string, JsonValue> | null | undefined,
|
|
60
|
+
schema: FormSchema | null | undefined,
|
|
61
|
+
): RedactResult {
|
|
62
|
+
if (!payload) {
|
|
63
|
+
return { redacted: {}, droppedFields: 0, droppedFieldNames: [] };
|
|
64
|
+
}
|
|
65
|
+
const secrets = new Set(schema?.secretFields ?? []);
|
|
66
|
+
const redacted: Record<string, JsonValue> = {};
|
|
67
|
+
const dropped: string[] = [];
|
|
68
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
69
|
+
if (secrets.has(key)) {
|
|
70
|
+
dropped.push(key);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
redacted[key] = value;
|
|
74
|
+
}
|
|
75
|
+
dropped.sort();
|
|
76
|
+
return { redacted, droppedFields: dropped.length, droppedFieldNames: dropped };
|
|
77
|
+
}
|
|
@@ -601,6 +601,17 @@ FOR (up:UserProfile) REQUIRE (up.accountId, up.userId) IS UNIQUE;
|
|
|
601
601
|
//
|
|
602
602
|
// Categories: communication, scheduling, decision, workflow,
|
|
603
603
|
// content, interaction
|
|
604
|
+
//
|
|
605
|
+
// Recognised optional properties:
|
|
606
|
+
// notApplicable (boolean) — Task 888. When `true`, the Preference
|
|
607
|
+
// records that the user explicitly declared the (category, key)
|
|
608
|
+
// does not apply to them. Counted as covered for the field-level
|
|
609
|
+
// Coverage signal in `formatProfileSummary`; filtered from the
|
|
610
|
+
// user-facing body (no value to render). Immutable on the tool
|
|
611
|
+
// surface (`profile-update` rejects mode merge|contradict). Always
|
|
612
|
+
// written with confidence: 1.0. No constraint, no index — the
|
|
613
|
+
// read query in loadUserProfile / profile-read widens the WHERE
|
|
614
|
+
// to include `OR pref.notApplicable = true`.
|
|
604
615
|
// ----------------------------------------------------------
|
|
605
616
|
|
|
606
617
|
CREATE CONSTRAINT preference_id_unique IF NOT EXISTS
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
"plugins/*/mcp"
|
|
7
7
|
],
|
|
8
8
|
"scripts": {
|
|
9
|
-
"build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
|
|
10
|
-
"build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json",
|
|
9
|
+
"build": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json && NODE_OPTIONS='--max-old-space-size=8192' tsc -b plugins/*/mcp/tsconfig.json",
|
|
10
|
+
"build:lib": "tsc -p lib/models/tsconfig.json && tsc -p lib/anthropic-key/tsconfig.json && tsc -p lib/oauth-llm/tsconfig.json && tsc -p lib/mcp-stderr-tee/tsconfig.json && tsc -p lib/mcp-spawn-tee/tsconfig.json && tsc -p lib/graph-write/tsconfig.json && tsc -p lib/graph-mcp/tsconfig.json && tsc -p lib/graph-trash/tsconfig.json && tsc -p lib/graph-search/tsconfig.json && tsc -p lib/screening-patterns/tsconfig.json && tsc -p lib/device-url/tsconfig.json && tsc -p lib/brand-templating/tsconfig.json && tsc -p lib/entitlement/tsconfig.json && tsc -p lib/task-secrets/tsconfig.json && tsc -p plugins/whatsapp-import/lib/tsconfig.json",
|
|
11
11
|
"build:memory": "tsc -p plugins/memory/mcp/tsconfig.json",
|
|
12
12
|
"build:contacts": "tsc -p plugins/contacts/mcp/tsconfig.json",
|
|
13
13
|
"build:telegram": "tsc -p plugins/telegram/mcp/tsconfig.json",
|
|
@@ -10,14 +10,14 @@ Applies when the business owner wants to set up, complete, or update their busin
|
|
|
10
10
|
## When to Activate
|
|
11
11
|
|
|
12
12
|
- Admin asks to set up, edit, or complete their business profile
|
|
13
|
-
- Onboarding step 9 delegates here on first run **only when the operator picked `business-owner` mode
|
|
13
|
+
- Onboarding step 9 delegates here on first run **only when the operator picked `business-owner` mode**. Personal mode bootstraps a `Person` with `role: "admin-personal"` directly inside step 9 and does NOT invoke this skill.
|
|
14
14
|
- Session-start graph check shows a `LocalBusiness` node with missing data (no hours, no services, empty description)
|
|
15
15
|
- The graph-write gate rejects a `memory-write` or `memory-update` with `no-admin-user` or `no-local-business` — the error message directs the agent here. (For personal-mode accounts, the gate is satisfied by the personal-profile `Person` node, so this rejection should not fire — if it does, the personal-profile node is missing or its `role` is wrong.)
|
|
16
16
|
- Admin asks questions like "update my address", "add our opening hours", "change the phone number"
|
|
17
17
|
|
|
18
18
|
## First-run path (no LocalBusiness yet)
|
|
19
19
|
|
|
20
|
-
The `AdminUser` and the operator's personal-profile `Person`
|
|
20
|
+
The `AdminUser` and the operator's personal-profile `Person` are written deterministically at PIN setup time, so this skill no longer creates the AdminUser. When onboarding step 9 invokes this skill in `business-owner` mode, or when the graph-write gate reports `no-local-business`, create the LocalBusiness:
|
|
21
21
|
|
|
22
22
|
1. **LocalBusiness.** Ask the user for the business name first — this is the only mandatory property. Call `memory-write` with `labels: ["LocalBusiness"]`, minimal `properties: { name }`, `scope: "shared"`, `relationships: [{ type: "OWNS", direction: "incoming", targetNodeId: "<AdminUser-elementId>" }]`. The `LocalBusiness` label is on the exempt set, so the gate passes for this write. Find the AdminUser elementId via `memory-search` for the operator's name; the AdminUser already exists from PIN setup.
|
|
23
23
|
2. **Capture the remaining domains** (address, hours, services, FAQs, brand assets) via `memory-update` on the `LocalBusiness` node and `memory-write` for related nodes (`OpeningHoursSpecification`, `Service`, `FAQPage`, `ImageObject`). With LocalBusiness present, the gate passes for all subsequent writes.
|
|
@@ -203,19 +203,20 @@ Pin the operator's persona and bootstrap the graph nodes that satisfy the graph-
|
|
|
203
203
|
|
|
204
204
|
**Call `onboarding-step9-mode` with the chosen mode before any graph write or skill invocation.** The tool emits the diagnostic log line and returns the deterministic next-action prose.
|
|
205
205
|
|
|
206
|
-
**Open the action record with `task-create` (
|
|
206
|
+
**Open the action record with `task-create` (process-provenance + audit input contract).** Before any graph write or skill invocation, call `task-create` with:
|
|
207
207
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
```
|
|
208
|
+
- `name`: "Establish operator owner — onboarding step 9"
|
|
209
|
+
- `description`: a one-line summary describing the chosen mode and what entities will be produced
|
|
210
|
+
- `status`: `"running"`
|
|
211
|
+
- `kind`: `"onboarding-establish-owner"`
|
|
212
|
+
- `inputsProvided`: the keys you actually pass on `inputs` (e.g. `["mode"]`); records the call shape
|
|
213
|
+
- `inputs`: the form payload — at minimum `{ mode: "personal" | "business-owner" }`. Add other operator-supplied non-secret fields you want the audit panel to render (e.g. when the user later provides email/phone in step 9 personal mode, you can append a follow-up `task-update` rather than reopening the Task)
|
|
214
|
+
- `inputSchema`: `{ secretFields: [] }` — the step-9 mode select carries no secrets; declare the empty list explicitly so the contract is visible at the call site
|
|
216
215
|
|
|
217
216
|
The returned `taskId` is the action-provenance handle for this step — every subsequent `memory-write` for an action-provenance-gated label (`Person`, `UserProfile`, `AdminUser`, `Organization`, `LocalBusiness`) MUST pass it as `producedByTaskId` so the inbound `:PRODUCED` edge from the Task is composed into the write. The Task is auto-linked to the current `AdminConversation` via `RAISED_DURING` (this is what makes `MATCH (c:AdminConversation)<-[:RAISED_DURING]-(t:Task)-[:PRODUCED]->(entity)` traversable from the conversation that initiated onboarding).
|
|
218
217
|
|
|
218
|
+
The `inputs` you pass land on the Task as `inputs.<field>` props after `redactSecrets` strips any secret-tagged keys. The contract is enforced centrally — never re-classify what is or isn't a secret at the call site; declare the form's secret fields once in `inputSchema.secretFields`.
|
|
219
|
+
|
|
219
220
|
Then branch on the mode.
|
|
220
221
|
|
|
221
222
|
### `business-owner`
|
|
@@ -235,4 +236,6 @@ Personal mode does not register a `LocalBusiness`. The `AdminUser` and personal-
|
|
|
235
236
|
|
|
236
237
|
After step 9 completes in personal mode, tell the user that {{productName}} is configured for personal use — their employer (if any) is not registered here. If they later become the operator for a business of their own, they can ask {{productName}} to set up a business profile, which invokes the `business-profile` skill directly.
|
|
237
238
|
|
|
239
|
+
**Post-onboarding work-preference elicitation (Task 888) — no step-9 change.** After step 9 completes, the per-turn `## About the Owner` block surfaces the field-level Coverage signal as the canonical post-onboarding elicitation source: `### Coverage / Missing: communication.preferredChannel, scheduling.workdayStartTime, …`. Onboarding deliberately does NOT pre-elicit these in a questionnaire — the agent drips them organically, one per turn, via the IDENTITY.md § Conversational Memory contract. Step 9's role is to bootstrap identity + persona; work-preferences are accumulated through normal conversation thereafter, with `notApplicable: true` covering the "doesn't apply to me" case (declined fields stop re-prompting).
|
|
240
|
+
|
|
238
241
|
If the user declines to bootstrap during step 9 in any mode, leave step 9 incomplete AND call `task-update(taskId, status:"failed", errorMessage:"<one-line reason>")` so the action record reflects the abandonment instead of dangling in `running` forever. The next session will resume here with a fresh `task-create` (the prior failed Task stays in the graph as the audit record). Any attempt to write user-domain data will surface `Write blocked (no-admin-user)` or `Write blocked (no-local-business)` via the gate, pulling the agent back into this step.
|
|
@@ -22,7 +22,7 @@ Built-in plugins under `$PLATFORM_ROOT/plugins/`:
|
|
|
22
22
|
|
|
23
23
|
- Enable: call `plugin-toggle-enabled` with `pluginName` and `action: "enable"`. Activates the plugin's behaviour embed and MCP server from the next session. Plugins with scheduled behaviour (declared via `lifecycle` in PLUGIN.md frontmatter) are activated automatically by the platform heartbeat within one minute — no separate setup command needed.
|
|
24
24
|
- Disable: call `plugin-toggle-enabled` with `pluginName` and `action: "disable"`. Deactivates from the next session. Any scheduled Events owned by the plugin (`sourcePlugin` field) are cancelled automatically by the platform heartbeat.
|
|
25
|
-
- The tool refuses core plugins (admin, memory, docs, cloudflare, anthropic) and refuses plugins not installed under `$PLATFORM_ROOT/plugins/`. Direct `Edit` of `account.json` is denied by the pre-tool-use hook
|
|
25
|
+
- The tool refuses core plugins (admin, memory, docs, cloudflare, anthropic) and refuses plugins not installed under `$PLATFORM_ROOT/plugins/`. Direct `Edit` of `account.json` is denied by the pre-tool-use hook — `plugin-toggle-enabled` is the only legitimate path.
|
|
26
26
|
|
|
27
27
|
## Premium Plugins
|
|
28
28
|
|
|
@@ -38,11 +38,11 @@ When the user asks about a specific premium plugin (e.g., "tell me about the tea
|
|
|
38
38
|
|
|
39
39
|
### Recording a purchase
|
|
40
40
|
|
|
41
|
-
`purchasedPlugins` is an entitlement-bearing field
|
|
41
|
+
`purchasedPlugins` is an entitlement-bearing field: the effective list derives from the Rubytech-signed entitlement payload, not from raw `account.json`. The pre-tool-use hook denies any agent write to `account.json`. Direct purchase recording is therefore unavailable to the agent.
|
|
42
42
|
|
|
43
|
-
- **Production path:**
|
|
43
|
+
- **Production path:** the Rubytech-side issuance endpoint signs an updated entitlement payload after a Stripe purchase event and delivers it to `~/<configDir>/entitlement.json`. The verifier picks up the new payload at the next session start.
|
|
44
44
|
- **Pre-launch / dev path:** the founder runs `node platform/scripts/generate-entitlement-fixture.mjs sign --account-id <id> --email <e> --tier <t> --plugins a,b,c --out ~/<configDir>/entitlement.json` to produce a signed test payload. The agent does not invoke this script.
|
|
45
|
-
- **Personal-mode installs (`brand.json.commercialMode: false`, the default today):** purchasedPlugins is read from raw `account.json` via implicit-trust mode. Pre-launch the agent has no way to add purchases without operator intervention; this is intentional during the gap before
|
|
45
|
+
- **Personal-mode installs (`brand.json.commercialMode: false`, the default today):** purchasedPlugins is read from raw `account.json` via implicit-trust mode. Pre-launch the agent has no way to add purchases without operator intervention; this is intentional during the gap before signed-issuance ships.
|
|
46
46
|
|
|
47
47
|
If the user asks the agent to record a purchase, explain that their purchase is processed by Rubytech billing and the entitlement payload arrives on their device automatically — no agent action is required.
|
|
48
48
|
|
|
@@ -195,7 +195,7 @@ After creation, no template metadata persists in the agent's files. The resultin
|
|
|
195
195
|
9. **KNOWLEDGE.md generation** — populate from the now-tagged set plus keyword matches using the `update-knowledge` skill workflow
|
|
196
196
|
10. Write `config.json` with selected model, plugins, status "active", `liveMemory`, and `knowledgeKeywords`. This is the last gated write — placed after IDENTITY.md, SOUL.md, and KNOWLEDGE.md to prevent cascade failure if one gate stalls.
|
|
197
197
|
11. Check context budget — auto-summarise if over threshold
|
|
198
|
-
12. **Project the agent into the graph** — delegate to the `database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project
|
|
198
|
+
12. **Project the agent into the graph** — delegate to the `database-operator` specialist with the instruction: "Project public agent `{slug}` into the graph by POSTing to `/api/admin/agents/{slug}/project`." The route reads the on-disk files and idempotently MERGEs the `:Agent` node, the four owned `:KnowledgeDocument` projections (IDENTITY/SOUL/KNOWLEDGE/KNOWLEDGE-SUMMARY when present, namespaced `attachmentId="agent:<slug>:<role>"`), the `HAS_*` edges, and the `USES_KNOWLEDGE` edges to every operator-tagged doc. Loud-fail: if the route returns non-2xx, surface the error to the user verbatim — the agent's files exist on disk but its graph projection is incomplete, which means the operator's /graph view will not show this agent. Re-running the projection is safe; it is the same idempotent MERGE.
|
|
199
199
|
13. Confirm creation: "Agent created. Visitors can reach it at `/{slug}`"
|
|
200
200
|
|
|
201
201
|
### List
|
|
@@ -245,7 +245,7 @@ Deletion removes the entire agent directory (`agents/{slug}/`) — not individua
|
|
|
245
245
|
- If no other agents remain (or the user declines to set a new default), clear the `defaultAgent` field by calling `account-update` with `field: "defaultAgent"`, `value: ""`. Tell the user that visitors to the root URL will see a "no agents" message until a new agent is created.
|
|
246
246
|
|
|
247
247
|
**Cleanup sequence:**
|
|
248
|
-
- Issue `DELETE /api/admin/agents/{slug}` — the route
|
|
248
|
+
- Issue `DELETE /api/admin/agents/{slug}` — the route removes the `:Agent` node and its four owned `:KnowledgeDocument` projections FIRST, then deletes the directory. Loud-fail: if graph cleanup throws, files are preserved and a 500 is returned with the error text. Re-issuing the DELETE is safe (idempotent on both layers). Operator-tagged docs survive the projection delete — only the `USES_KNOWLEDGE` edges from this Agent are removed.
|
|
249
249
|
- Verify the directory no longer exists
|
|
250
250
|
- Report what was removed (list the files that were in the directory)
|
|
251
251
|
|
|
@@ -10,7 +10,7 @@ description: >
|
|
|
10
10
|
|
|
11
11
|
# Stream Log Review
|
|
12
12
|
|
|
13
|
-
Analyse Claude agent stream logs — the `claude-agent-stream-{conversationId}.log` files generated by the platform
|
|
13
|
+
Analyse Claude agent stream logs — the `claude-agent-stream-{conversationId}.log` files generated by the platform. Each file is scoped to exactly one conversation; a resumed conversation accumulates in one file across multiple process lifetimes, delimited by `[spawn]` / `[process-exit]`. Pre-conversation events (CDP auth-poll, module-init warnings) live in `preconversation-claude-agent-stream-{YYYY-MM-DD}.log` — a separate file per UTC day, read only when investigating boot-time failures.
|
|
14
14
|
|
|
15
15
|
Parse the log, identify problems, attempt diagnosis, and produce a structured report.
|
|
16
16
|
|
|
@@ -35,7 +35,7 @@ Parse the log, identify problems, attempt diagnosis, and produce a structured re
|
|
|
35
35
|
On the Pi, use the `logs-read` MCP tool with `conversationId` to retrieve a single conversation's log. From the dev machine (SSH), use the shell counterpart:
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
# Read a single conversation's stream log (primary mode
|
|
38
|
+
# Read a single conversation's stream log (primary mode)
|
|
39
39
|
sshpass -p 'password' ssh admin@<hostname>.local "~/<installDir>/platform/scripts/logs-read.sh <conversationId> system"
|
|
40
40
|
|
|
41
41
|
# Tail the most-recently-active stream log (any conversation)
|
|
@@ -53,11 +53,11 @@ The `conversationId` is visible in the admin UI and appears on every `[spawn]` /
|
|
|
53
53
|
- `[tool-wait-diag]` at 15 / 30 / 45 / 60 s — confirms DNS/TCP/HTTP health across a stall window (not just at the timeout moment).
|
|
54
54
|
- `[tool-wait-proc]` at 30 s — subprocess open FDs, sockets by TCP state, RSS.
|
|
55
55
|
- `[subproc-stderr]` — line from the main Claude Code subprocess's stderr. Today the CLI is a bundled Bun binary that ignores Node's `NODE_DEBUG`, so this channel is normally silent — see `[subproc-debug-unavailable]` below. MCP server stderr lines arrive separately as `[mcp:<server>]`.
|
|
56
|
-
- `[subproc-stderr-tee-attached]` / `[subproc-stderr-tee-detached]`
|
|
57
|
-
- `[subproc-debug-unavailable]`
|
|
58
|
-
- `[tool-failure-diag]` — one-shot probe on the failure path
|
|
56
|
+
- `[subproc-stderr-tee-attached]` / `[subproc-stderr-tee-detached]` — lifecycle of the main-subprocess stderr tee. `bytes=0 lines=0` on detach means the tee worked but the subprocess emitted nothing. Absence of both markers next to a `[spawn]` means the tee infrastructure is broken — escalate.
|
|
57
|
+
- `[subproc-debug-unavailable]` — one line per spawn, `reason=bundled-bun-binary-ignores-node-debug`. This is the documented reason `[subproc-stderr]` lines are normally absent for the main subprocess. Treat its absence as a regression, not its presence as a problem.
|
|
58
|
+
- `[tool-failure-diag]` — one-shot probe on the failure path; complements the mid-flight `[tool-wait-diag]` with end-of-wait network state.
|
|
59
59
|
- `[mcp-tee-attach]` / `[mcp-tee-skip]` / `[mcp:<server>]` — MCP server stderr routed into the stream log; a missing attach marker explains missing server diagnostics.
|
|
60
|
-
- `[tool-result] error=true output="WEBFETCH_CANNOT_READ_JS_SPA: …"` — a WebFetch dispatch was short-circuited by the SPA preflight hook
|
|
60
|
+
- `[tool-result] error=true output="WEBFETCH_CANNOT_READ_JS_SPA: …"` — a WebFetch dispatch was short-circuited by the SPA preflight hook. The URL is a JS-SPA shell that WebFetch cannot extract content from. The agent should have responded to the user naming the failure and asking for a paste or screenshot; if instead an `[agent-dispatch]` (Playwright, research-assistant) follows immediately, the loud-failure guidance in IDENTITY.md did not land. The hook's per-invocation decision trail lives in `{accountDir}/logs/webfetch-preflight.log` (one `[webfetch-preflight] verdict=…` line per probe).
|
|
61
61
|
|
|
62
62
|
## Boundaries
|
|
63
63
|
|
|
@@ -78,4 +78,4 @@ All of these are from `unzip` (InfoZIP) + `coreutils` — installed on every Pi
|
|
|
78
78
|
|
|
79
79
|
## Why this is a skill, not a tool
|
|
80
80
|
|
|
81
|
-
Doctrine
|
|
81
|
+
Doctrine: the admin agent has `Bash`; the security-critical code path is a sequence of shell commands whose determinism is enforced by the shell primitives themselves, not by LLM reasoning. Wrapping this in an MCP tool would add a translation layer without adding enforcement — a tool wrapper is still LLM-mediated at the decision boundary. The shell script is the deterministic primitive; the skill tells the agent which primitive to invoke, in which order, against which argument.
|
|
@@ -217,7 +217,7 @@ cloudflared --origincert "${CFG_DIR}/cert.pem" tunnel route dns --overwrite-dns
|
|
|
217
217
|
2. Overwritten: `Added CNAME <hostname> which will route to this tunnel` (same shape as new; `--overwrite-dns` makes overwrite transparent)
|
|
218
218
|
3. Idempotent no-op: `<timestamp> INF <hostname> is already configured to route to your tunnel tunnelID=<UUID>`
|
|
219
219
|
|
|
220
|
-
Shape 3 is what a second clean run of setup-tunnel.sh against an already-configured hostname emits. Historically the shell script's stdout parser rejected this shape and exited 1 on the idempotent case
|
|
220
|
+
Shape 3 is what a second clean run of setup-tunnel.sh against an already-configured hostname emits. Historically the shell script's stdout parser rejected this shape and exited 1 on the idempotent case; the script now relies on cloudflared's exit code exclusively.
|
|
221
221
|
|
|
222
222
|
**If it fails with `zone not found`:** the hostname's parent domain isn't on this brand's Cloudflare account. Either add it in the dashboard (Websites → Add a site) and re-run, or sign into the account that already owns the domain.
|
|
223
223
|
|
|
@@ -325,14 +325,14 @@ You do **not** run `cloudflared` manually. The brand's existing user-space syste
|
|
|
325
325
|
systemctl --user restart "${BRAND}.service"
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
-
**Why the script dispatches the restart via `systemd-run` instead of a direct `systemctl restart
|
|
328
|
+
**Why the script dispatches the restart via `systemd-run` instead of a direct `systemctl restart`:** when the admin agent invokes `setup-tunnel.sh` via the Bash tool, the script runs *inside* `${BRAND}.service`'s cgroup. A direct `systemctl --user restart ${BRAND}.service` from that cgroup tells systemd to SIGTERM the entire cgroup — the node server, the claude subprocess, the Bash child, and the script itself all die simultaneously. cgroup membership is inherited: `setsid`, `nohup`, `disown`, and `&` all stay in the caller's cgroup, and `systemd-run --scope` runs in the caller's scope. Only `systemd-run --user --unit=<name> --on-active=<N>s` creates a genuinely new transient unit with its own cgroup. The script uses that primitive to arm the restart a few seconds after its own exit:
|
|
329
329
|
|
|
330
330
|
```
|
|
331
331
|
systemd-run --user --unit=maxy-tunnel-restart-<nonce>.service --on-active=3s --collect \
|
|
332
332
|
/bin/systemctl --user restart "${BRAND}.service"
|
|
333
333
|
```
|
|
334
334
|
|
|
335
|
-
The script then emits `[script:setup-tunnel] step=service-restart-dispatched` and `step=service-restart-armed exit=0` in the per-conversation stream log so operators see exactly when the restart was scheduled, exits 0, and the transient timer fires from outside the service's cgroup — semantically identical to this manual runbook's `systemctl --user restart`. (The `script:` prefix is
|
|
335
|
+
The script then emits `[script:setup-tunnel] step=service-restart-dispatched` and `step=service-restart-armed exit=0` in the per-conversation stream log so operators see exactly when the restart was scheduled, exits 0, and the transient timer fires from outside the service's cgroup — semantically identical to this manual runbook's `systemctl --user restart`. (The `script:` prefix is the chat-surface namespace — see `_stream-log.sh` header.)
|
|
336
336
|
|
|
337
337
|
When walking through manually you do **not** need `systemd-run` — your SSH shell already lives in a separate user-scope cgroup (`user@<uid>.service`), so the direct `systemctl restart` does not kill the caller. The script's extra indirection only matters when the caller *is* the service being restarted.
|
|
338
338
|
|
|
@@ -20,9 +20,9 @@ Any Cloudflare action outside these four surfaces is a discipline violation —
|
|
|
20
20
|
|
|
21
21
|
## 1. Autonomous path — `setup-tunnel.sh`
|
|
22
22
|
|
|
23
|
-
Use this when the operator wants Cloudflare set up (or re-set up) end-to-end on the device. The script handles OAuth login, tunnel creation, DNS routing for each subdomain, config.yml + tunnel.state, and dispatches the `${BRAND}.service` restart to a transient `systemd-run` unit
|
|
23
|
+
Use this when the operator wants Cloudflare set up (or re-set up) end-to-end on the device. The script handles OAuth login, tunnel creation, DNS routing for each subdomain, config.yml + tunnel.state, and dispatches the `${BRAND}.service` restart to a transient `systemd-run` unit — all in one invocation. The restart fires a few seconds after the script exits so the script does not kill its own cgroup when invoked via the Bash tool; the chat UI receives a `server_shutdown` SSE frame and reconnects automatically. Post-restart hostname verification is out of scope for the script (connector is not up when the script exits) — verify via the next admin turn or manually with `curl -I https://<hostname>`. Apex hostnames cannot be routed by the CLI; when one is passed, the script prints an `ACTION REQUIRED` block naming the exact dashboard record to edit.
|
|
24
24
|
|
|
25
|
-
Step 1's OAuth flow is a state machine over two observable variables: the brand-scoped cert path (`${CFG_DIR}/cert.pem`) and the OAuth-default cert path (`~/.cloudflared/cert.pem`). When the brand-scoped cert is missing but the default-path cert is present from any prior partial run, the wrapper promotes it (`mv`) and emits `step=oauth-login result=ok reason=cert-promoted-from-default-path` without re-spawning cloudflared. When both are missing, the wrapper spawns `cloudflared tunnel login`, extracts the argotunnel URL from its stdout, and the instant the URL surfaces, mechanically opens it on the Pi's VNC chromium (`DISPLAY=${DISPLAY:-:99} /usr/bin/chromium <url> &`) — emitting `step=browser-spawn result=ok` and `step=browser-drive mode=operator-click`. The operator clicks the zone row + Authorize on the VNC; cloudflared's callback writes `~/.cloudflared/cert.pem`; the wrapper's cert-poll (180 s budget) picks it up and `mv`s it to the brand-scoped path. There is no CDP auto-click, no DOM matcher, no consent-page driver — the wrapper's job is to faithfully relay `cloudflared tunnel login`, never to layer automation on top
|
|
25
|
+
Step 1's OAuth flow is a state machine over two observable variables: the brand-scoped cert path (`${CFG_DIR}/cert.pem`) and the OAuth-default cert path (`~/.cloudflared/cert.pem`). When the brand-scoped cert is missing but the default-path cert is present from any prior partial run, the wrapper promotes it (`mv`) and emits `step=oauth-login result=ok reason=cert-promoted-from-default-path` without re-spawning cloudflared. When both are missing, the wrapper spawns `cloudflared tunnel login`, extracts the argotunnel URL from its stdout, and the instant the URL surfaces, mechanically opens it on the Pi's VNC chromium (`DISPLAY=${DISPLAY:-:99} /usr/bin/chromium <url> &`) — emitting `step=browser-spawn result=ok` and `step=browser-drive mode=operator-click`. The operator clicks the zone row + Authorize on the VNC; cloudflared's callback writes `~/.cloudflared/cert.pem`; the wrapper's cert-poll (180 s budget) picks it up and `mv`s it to the brand-scoped path. There is no CDP auto-click, no DOM matcher, no consent-page driver — the wrapper's job is to faithfully relay `cloudflared tunnel login`, never to layer automation on top.
|
|
26
26
|
|
|
27
27
|
### How inputs reach the script
|
|
28
28
|
|
|
@@ -52,7 +52,7 @@ The agent does not invoke the script directly during onboarding — the endpoint
|
|
|
52
52
|
|
|
53
53
|
The endpoint returns `{ ok: false, field: "script", message, output }` and the form surfaces the error inline. Relay the output to the user, name the exit code, and cite `references/reset-guide.md` for the next action. Offer to re-render the form after any manual steps the script's error output named. Do not attempt a second invocation outside the form, a Playwright-driven dashboard inspection, or an alternative `cloudflared` command sequence. The discipline rule below applies.
|
|
54
54
|
|
|
55
|
-
When the failure reason is `timeout-waiting-cert` (
|
|
55
|
+
When the failure reason is `timeout-waiting-cert` (operator did not click Authorize within the 180 s budget), the form surfaces the timeout and the operator can re-submit. The page is still on the Pi VNC; the operator can click Authorize there and the next form submit will complete via the cert-promotion pre-flight (the cert lands in `~/.cloudflared/cert.pem` after consent, and the wrapper's `mv` runs on the next invocation). Do not suggest `~/reset-tunnel.sh` — the cert path is intact and a fresh attempt is the only remediation needed.
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
@@ -90,7 +90,7 @@ Example:
|
|
|
90
90
|
|
|
91
91
|
Use this when the operator needs to do something only the Cloudflare dashboard can do: sign in, switch accounts, add a site, edit an apex CNAME, verify zone nameservers, delete a tunnel after stopping its replicas. The guide has one numbered click-path per operation. Quote the relevant click-path verbatim — the operator follows it in the browser. The agent does not drive dashboard mutations via Playwright or Chrome DevTools.
|
|
92
92
|
|
|
93
|
-
The single exception is `list-cf-domains.sh
|
|
93
|
+
The single exception is `list-cf-domains.sh`, which reads the domains attached to the logged-in account to populate the `cloudflare-setup-form` dropdowns. That script is deterministic (bash + raw CDP, no LLM in the decision path), invoked only by the `/api/admin/cloudflare/domains` route, and produces only a JSON `string[]` on stdout; no dashboard state is changed. Any dashboard scrape that is not this exact script is forbidden — the agent does not extend this carve-out to new scripts it writes, hypothesises, or finds. Adding a new sanctioned scrape surface requires a code change reviewed as a doctrine change, not an inline agent decision.
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
@@ -30,7 +30,7 @@ When you submit, the `/api/admin/cloudflare/setup` endpoint runs — in strict o
|
|
|
30
30
|
- `cloudflared tunnel route dns` for each subdomain hostname. Apex hostnames cannot be routed this way — the script prints an **ACTION REQUIRED** block naming the exact dashboard record to add or edit. Stream log emits `step=route-dns hostname=… tunnel_id=…` before the call and `step=route-dns hostname=… result=ok|apex-skip|error` after; on error the bounded cloudflared stderr (≤400 chars) rides in the same phase line. **The script does not parse cloudflared's stdout** — exit code is the sole decision signal, so all three legitimate cloudflared output shapes (new record, overwrite, idempotent "already configured") are treated as success.
|
|
31
31
|
- `config.yml` and `tunnel.state` written under `${CFG_DIR}`.
|
|
32
32
|
- **Step-7 onboarding completion persisted** — the script writes `${ACCOUNT_DIR}/onboarding/step7-complete` (a JSON marker with the completion timestamp and tunnel ID) before arming the restart. Stream log: `step=onboarding-persist result=ok|error reason=<r>`. The marker is consumed by the next admin session's first state read and advances `OnboardingState.currentStep` to 7. Without this, the service restart below would SIGTERM the admin agent before it could persist step-7 completion, and the next session would re-ask the Cloudflare question you just finished. Both invocation surfaces (the form-driven action and the agent-via-Bash path) declare `ACCOUNT_DIR` explicitly because `systemd-run --user` does not inherit parent env — when ACCOUNT_DIR isn't reaching the script you'll see `result=skipped reason=no-account-dir` in the stream log instead of `result=ok`.
|
|
33
|
-
- **Post-restart resume contract** — when the
|
|
33
|
+
- **Post-restart resume contract** — when the script exits cleanly, the form fires a client-side resume event. The chat hook ([useAdminChat.ts](../../../../platform/ui/app/useAdminChat.ts)) waits for the brand-service to come back via `/api/health` (down-then-up), re-binds the conversation to the new server process, and sends the "Cloudflare setup completed" marker as a normal hidden chat POST that re-invokes the agent in a fresh session. No relay queue, no boot-drain, no banner. Diagnostic: `grep '\[admin-resume\] reason=post-restart' ~/{configDir}/logs/server.log` (expect one line per restart cycle), `grep '\[client-event\] kind=post-restart-resume' ~/{configDir}/logs/server.log` for the operator-visible client trace. See `.docs/web-chat.md` "Post-restart resume contract" for the full client/server contract.
|
|
34
34
|
- `systemctl --user restart ${BRAND}.service` — restarts the platform service so the new tunnel spawns via the service's `ExecStartPre=resume-tunnel.sh`.
|
|
35
35
|
- Post-restart verification — `ps -ef | grep '[c]loudflared'` confirms the connector is alive, then `curl -I https://<hostname>` against each subdomain (up to 60 s per host) confirms a non-530 response.
|
|
36
36
|
|