@tpsdev-ai/flair 0.8.3 → 0.10.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/README.md +36 -11
- package/dist/cli.js +1740 -377
- package/dist/rem/restore.js +265 -0
- package/dist/rem/runner.js +221 -0
- package/dist/rem/scheduler.js +240 -0
- package/dist/rem/snapshot.js +139 -0
- package/dist/render.js +168 -0
- package/dist/resources/A2AAdapter.js +35 -0
- package/dist/resources/Admin.js +16 -0
- package/dist/resources/AdminInstance.js +86 -4
- package/dist/resources/AdminMemory.js +7 -1
- package/dist/resources/Federation.js +64 -33
- package/dist/resources/MemoryMaintenance.js +50 -22
- package/dist/resources/OAuth.js +22 -0
- package/dist/resources/ObservationCenter.js +4 -0
- package/dist/resources/SkillScan.js +32 -89
- package/dist/resources/admin-layout.js +25 -1
- package/dist/resources/auth-middleware.js +23 -3
- package/dist/resources/federation-classify.js +29 -0
- package/dist/resources/health.js +10 -5
- package/dist/resources/scan/skill-scanner.js +166 -0
- package/package.json +2 -1
- package/schemas/federation.graphql +6 -3
- package/templates/bin/flair-rem-nightly.sh.tmpl +9 -0
- package/templates/launchd/dev.flair.rem.nightly.plist.tmpl +46 -0
- package/templates/systemd/flair-rem-nightly.service.tmpl +14 -0
- package/templates/systemd/flair-rem-nightly.timer.tmpl +10 -0
|
@@ -110,12 +110,18 @@ async function backfillEmbedding(memoryId) {
|
|
|
110
110
|
// ─── HTTP middleware ──────────────────────────────────────────────────────────
|
|
111
111
|
server.http(async (request, nextLayer) => {
|
|
112
112
|
const url = new URL(request.url, "http://" + (request.headers.get("host") || "localhost"));
|
|
113
|
+
// A2A discovery endpoints: GET returns public agent-card metadata (per
|
|
114
|
+
// A2A spec, cards are intentionally public). POST invokes JSON-RPC
|
|
115
|
+
// actions (message/send writes OrgEvents on behalf of agents,
|
|
116
|
+
// tasks/list reads Beads issues, message/stream subscribes to
|
|
117
|
+
// OrgEvents) — those must be authenticated. Narrowing to GET-only
|
|
118
|
+
// closes the P0 where any caller could forge OrgEvents as any agent
|
|
119
|
+
// and read all internal Beads issues unauthenticated.
|
|
120
|
+
const isA2APath = url.pathname === "/a2a" || url.pathname === "/A2AAdapter" || url.pathname.startsWith("/A2AAdapter/");
|
|
113
121
|
if (url.pathname === "/health" ||
|
|
114
122
|
url.pathname === "/Health" ||
|
|
115
|
-
|
|
116
|
-
url.pathname === "/A2AAdapter" ||
|
|
123
|
+
(request.method === "GET" && isA2APath) ||
|
|
117
124
|
url.pathname === "/AgentCard" ||
|
|
118
|
-
url.pathname.startsWith("/A2AAdapter/") ||
|
|
119
125
|
url.pathname.startsWith("/AgentCard/") ||
|
|
120
126
|
// FederationSync uses Ed25519 body-signature auth with anti-replay, validated
|
|
121
127
|
// by the resource handler (allowCreate=true, same pattern as FederationPair).
|
|
@@ -220,6 +226,20 @@ server.http(async (request, nextLayer) => {
|
|
|
220
226
|
// ── Ed25519 agent auth ────────────────────────────────────────────────────
|
|
221
227
|
const m = header.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
|
|
222
228
|
if (!m) {
|
|
229
|
+
// For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
|
|
230
|
+
// the browser shows a native auth dialog instead of a bare 401 page.
|
|
231
|
+
// JSON API endpoints don't get this — they should keep the structured
|
|
232
|
+
// 401 body so the client can parse the error.
|
|
233
|
+
const isAdminPage = url.pathname === "/Admin" || url.pathname.startsWith("/Admin");
|
|
234
|
+
if (isAdminPage) {
|
|
235
|
+
return new Response("Authentication required.", {
|
|
236
|
+
status: 401,
|
|
237
|
+
headers: {
|
|
238
|
+
"WWW-Authenticate": 'Basic realm="Flair Admin"',
|
|
239
|
+
"content-type": "text/plain; charset=utf-8",
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
}
|
|
223
243
|
return new Response(JSON.stringify({ error: "missing_or_invalid_authorization" }), { status: 401 });
|
|
224
244
|
}
|
|
225
245
|
const [, agentId, tsRaw, nonce, signatureB64] = m;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure classifier for federation sync records — no Harper imports.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from Federation.ts so the decision can be unit-tested without
|
|
5
|
+
* spinning up Harper's database module. The same SkipReason names are used
|
|
6
|
+
* in SyncLog.skippedReasons so operators can grep for them.
|
|
7
|
+
*/
|
|
8
|
+
export function classifyRecord(record, peerRole, receiverInstanceId, local, knownTables, now = new Date()) {
|
|
9
|
+
if (!knownTables.has(record.table)) {
|
|
10
|
+
return { action: "skip", reason: "unknown_table" };
|
|
11
|
+
}
|
|
12
|
+
const originator = record.originatorInstanceId ?? receiverInstanceId;
|
|
13
|
+
if (originator !== receiverInstanceId && peerRole !== "hub") {
|
|
14
|
+
return { action: "skip", reason: "non_originator" };
|
|
15
|
+
}
|
|
16
|
+
const fiveMinFromNow = new Date(now.getTime() + 5 * 60 * 1000).toISOString();
|
|
17
|
+
if (record.updatedAt > fiveMinFromNow) {
|
|
18
|
+
return { action: "skip", reason: "future_timestamp" };
|
|
19
|
+
}
|
|
20
|
+
const remoteContentHash = record.data?.contentHash;
|
|
21
|
+
if (local &&
|
|
22
|
+
local.contentHash &&
|
|
23
|
+
remoteContentHash &&
|
|
24
|
+
local.contentHash === remoteContentHash &&
|
|
25
|
+
record.updatedAt <= (local.updatedAt ?? "")) {
|
|
26
|
+
return { action: "skip", reason: "no_op_same_hash" };
|
|
27
|
+
}
|
|
28
|
+
return { action: "merge", originator };
|
|
29
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -201,13 +201,18 @@ export class HealthDetail extends Resource {
|
|
|
201
201
|
for await (const s of db.flair.Soul.search({}))
|
|
202
202
|
souls.push(s);
|
|
203
203
|
stats.soulEntries = souls.length;
|
|
204
|
-
|
|
204
|
+
// Soul entries have no severity dimension — they are keyed identity facts
|
|
205
|
+
// (role / project / standards / …). A per-priority breakdown was dead
|
|
206
|
+
// telemetry: nothing writes Soul.priority to anything but "standard", and
|
|
207
|
+
// the `?? "standard"` fallback also mislabelled *unset* as *standard*, so
|
|
208
|
+
// it always read 100% standard regardless of the data. Report the honest
|
|
209
|
+
// dimension instead — a count per key.
|
|
210
|
+
const byKey = {};
|
|
205
211
|
for (const s of souls) {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
byPriority[p]++;
|
|
212
|
+
const k = (s.key ?? "(unkeyed)");
|
|
213
|
+
byKey[k] = (byKey[k] ?? 0) + 1;
|
|
209
214
|
}
|
|
210
|
-
stats.soul = { total: souls.length,
|
|
215
|
+
stats.soul = { total: souls.length, byKey };
|
|
211
216
|
}
|
|
212
217
|
catch {
|
|
213
218
|
stats.soulEntries = null;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill content static analyzer — pure, no Harper runtime deps.
|
|
3
|
+
*
|
|
4
|
+
* Imported by `resources/SkillScan.ts` (the HTTP Resource) and by unit
|
|
5
|
+
* tests in `test/unit/SkillScan.test.ts`. Keeping the scanner logic in a
|
|
6
|
+
* separate module lets the tests run without instantiating the Harper
|
|
7
|
+
* runtime.
|
|
8
|
+
*
|
|
9
|
+
* See SkillScan.ts for the design rationale on markdown awareness and the
|
|
10
|
+
* language-agnostic vs shell-only pattern split.
|
|
11
|
+
*/
|
|
12
|
+
const SHELL_PATTERNS = [
|
|
13
|
+
{ regex: /\bexec\s*\(/, type: "shell_command" },
|
|
14
|
+
{ regex: /\bspawn\s*\(/, type: "shell_command" },
|
|
15
|
+
{ regex: /\bsystem\s*\(/, type: "shell_command" },
|
|
16
|
+
{ regex: /\bchild_process\b/, type: "shell_command" },
|
|
17
|
+
];
|
|
18
|
+
const NETWORK_PATTERNS = [
|
|
19
|
+
{ regex: /\bfetch\s*\(/, type: "network_call" },
|
|
20
|
+
{ regex: /\bcurl\b/, type: "network_call" },
|
|
21
|
+
{ regex: /https?:\/\//, type: "url_reference" },
|
|
22
|
+
{ regex: /\bXMLHttpRequest\b/, type: "network_call" },
|
|
23
|
+
{ regex: /\baxios\b/, type: "network_call" },
|
|
24
|
+
];
|
|
25
|
+
const FS_PATTERNS = [
|
|
26
|
+
{ regex: /\bfs\.write/, type: "fs_write" },
|
|
27
|
+
{ regex: /\bwriteFile/, type: "fs_write" },
|
|
28
|
+
{ regex: />[>]?\s*[\/~]/, type: "fs_redirect" },
|
|
29
|
+
];
|
|
30
|
+
const ENV_PATTERNS = [
|
|
31
|
+
{ regex: /\bprocess\.env\b/, type: "env_access" },
|
|
32
|
+
{ regex: /\$ENV\b/, type: "env_access" },
|
|
33
|
+
{ regex: /\$\{?\w+\}?/, type: "env_variable" },
|
|
34
|
+
];
|
|
35
|
+
const ENCODING_PATTERNS = [
|
|
36
|
+
{ regex: /\batob\s*\(/, type: "base64_decode" },
|
|
37
|
+
{ regex: /\bbtoa\s*\(/, type: "base64_encode" },
|
|
38
|
+
{ regex: /Buffer\.from\s*\([^)]*,\s*['"]base64['"]/, type: "base64_decode" },
|
|
39
|
+
{ regex: /Buffer\.from\s*\([^)]*,\s*['"]hex['"]/, type: "hex_decode" },
|
|
40
|
+
{ regex: /\\x[0-9a-fA-F]{2}/, type: "hex_escape" },
|
|
41
|
+
{ regex: /\\u200[b-f]|\\u2060|\\ufeff/, type: "zero_width_char" },
|
|
42
|
+
];
|
|
43
|
+
const UNICODE_PATTERNS = [
|
|
44
|
+
{ regex: /[-]/, type: "zero_width_char" },
|
|
45
|
+
{ regex: /[А-я]/, type: "cyrillic_homoglyph" },
|
|
46
|
+
];
|
|
47
|
+
const LANG_AGNOSTIC_PATTERNS = [
|
|
48
|
+
...NETWORK_PATTERNS,
|
|
49
|
+
...FS_PATTERNS,
|
|
50
|
+
...ENV_PATTERNS,
|
|
51
|
+
...ENCODING_PATTERNS,
|
|
52
|
+
...UNICODE_PATTERNS,
|
|
53
|
+
];
|
|
54
|
+
const SHELL_FENCE_LANGS = new Set(["", "sh", "bash", "shell", "zsh"]);
|
|
55
|
+
const MARKDOWN_IDENTIFIER_RE = /^[\w@./-]+$/;
|
|
56
|
+
const SHELL_BACKTICK_INDICATORS = [
|
|
57
|
+
/\s/,
|
|
58
|
+
/[|;&]/,
|
|
59
|
+
/\$\(/,
|
|
60
|
+
/\$\{/,
|
|
61
|
+
/^\$\w/,
|
|
62
|
+
/>/,
|
|
63
|
+
/<\(/,
|
|
64
|
+
];
|
|
65
|
+
function lineHasShellishBacktick(line) {
|
|
66
|
+
const matches = line.match(/`([^`\n]+)`/g);
|
|
67
|
+
if (!matches)
|
|
68
|
+
return false;
|
|
69
|
+
for (const raw of matches) {
|
|
70
|
+
const inner = raw.slice(1, -1);
|
|
71
|
+
if (MARKDOWN_IDENTIFIER_RE.test(inner))
|
|
72
|
+
continue;
|
|
73
|
+
for (const indicator of SHELL_BACKTICK_INDICATORS) {
|
|
74
|
+
if (indicator.test(inner))
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function assessRisk(violations) {
|
|
81
|
+
if (violations.length === 0)
|
|
82
|
+
return "low";
|
|
83
|
+
const types = new Set(violations.map((v) => v.type));
|
|
84
|
+
// Programmatic shell call patterns: exec/spawn/system/child_process. These
|
|
85
|
+
// are definite payloads when they appear in a skill.
|
|
86
|
+
const hasShellCommand = types.has("shell_command");
|
|
87
|
+
// Backticked shell-ish content in markdown prose. Could be `npm run deploy`
|
|
88
|
+
// in documentation (medium), or could combine with other smells (high).
|
|
89
|
+
const hasShellBacktick = types.has("shell_backtick");
|
|
90
|
+
const hasFs = types.has("fs_write") || types.has("fs_redirect");
|
|
91
|
+
const hasEncoding = types.has("base64_decode") || types.has("hex_decode");
|
|
92
|
+
const hasZeroWidth = types.has("zero_width_char");
|
|
93
|
+
const hasHomoglyph = types.has("cyrillic_homoglyph");
|
|
94
|
+
// Critical: any shell combined with encoded payloads, OR obfuscation chars.
|
|
95
|
+
if (((hasShellCommand || hasShellBacktick) && hasEncoding) ||
|
|
96
|
+
hasZeroWidth ||
|
|
97
|
+
hasHomoglyph) {
|
|
98
|
+
return "critical";
|
|
99
|
+
}
|
|
100
|
+
// High: programmatic shell, fs writes, or shell-backtick + other smells
|
|
101
|
+
// (env access, network call). A skill that quotes a command AND reads env
|
|
102
|
+
// AND fetches a URL is doing something, not just documenting.
|
|
103
|
+
const hasOtherSmells = types.has("env_access") ||
|
|
104
|
+
types.has("env_variable") ||
|
|
105
|
+
types.has("network_call") ||
|
|
106
|
+
types.has("url_reference");
|
|
107
|
+
if (hasShellCommand || hasFs || (hasShellBacktick && hasOtherSmells)) {
|
|
108
|
+
return "high";
|
|
109
|
+
}
|
|
110
|
+
// Medium: shell_backtick alone (doc reference), or network/encoding
|
|
111
|
+
// without shell. Reviewable but not blocked.
|
|
112
|
+
if (hasShellBacktick || hasOtherSmells || hasEncoding) {
|
|
113
|
+
return "medium";
|
|
114
|
+
}
|
|
115
|
+
return "low";
|
|
116
|
+
}
|
|
117
|
+
function detectFenceTransition(line, state) {
|
|
118
|
+
const m = line.match(/^[ \t]*```([\w.-]*)\s*$/);
|
|
119
|
+
if (!m)
|
|
120
|
+
return false;
|
|
121
|
+
if (state.inFence) {
|
|
122
|
+
state.inFence = false;
|
|
123
|
+
state.lang = "";
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
state.inFence = true;
|
|
127
|
+
state.lang = (m[1] || "").toLowerCase();
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
export function scanSkillContent(content) {
|
|
132
|
+
const lines = content.split("\n");
|
|
133
|
+
const violations = [];
|
|
134
|
+
const fence = { inFence: false, lang: "" };
|
|
135
|
+
const recordIfMatch = (lineIndex, line, patterns) => {
|
|
136
|
+
for (const pattern of patterns) {
|
|
137
|
+
if (pattern.regex.test(line)) {
|
|
138
|
+
violations.push({
|
|
139
|
+
type: pattern.type,
|
|
140
|
+
line: lineIndex + 1,
|
|
141
|
+
content: line.trim().slice(0, 200),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
for (let i = 0; i < lines.length; i++) {
|
|
147
|
+
const line = lines[i] ?? "";
|
|
148
|
+
if (detectFenceTransition(line, fence))
|
|
149
|
+
continue;
|
|
150
|
+
const insideShellFence = fence.inFence && SHELL_FENCE_LANGS.has(fence.lang);
|
|
151
|
+
const insideNonShellFence = fence.inFence && !insideShellFence;
|
|
152
|
+
if (!fence.inFence && lineHasShellishBacktick(line)) {
|
|
153
|
+
violations.push({
|
|
154
|
+
type: "shell_backtick",
|
|
155
|
+
line: i + 1,
|
|
156
|
+
content: line.trim().slice(0, 200),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (!insideNonShellFence) {
|
|
160
|
+
recordIfMatch(i, line, SHELL_PATTERNS);
|
|
161
|
+
}
|
|
162
|
+
recordIfMatch(i, line, LANG_AGNOSTIC_PATTERNS);
|
|
163
|
+
}
|
|
164
|
+
const riskLevel = assessRisk(violations);
|
|
165
|
+
return { safe: violations.length === 0, violations, riskLevel };
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"dist/",
|
|
32
32
|
"schemas/",
|
|
33
|
+
"templates/",
|
|
33
34
|
"ui/",
|
|
34
35
|
"config.yaml",
|
|
35
36
|
"LICENSE",
|
|
@@ -30,7 +30,8 @@ type Peer @table(database: "flair") @export {
|
|
|
30
30
|
role: String @indexed # "hub" | "spoke"
|
|
31
31
|
endpoint: String # wss:// URL for the peer
|
|
32
32
|
status: String @indexed # "paired" | "connected" | "disconnected" | "revoked"
|
|
33
|
-
lastSyncAt: String # last
|
|
33
|
+
lastSyncAt: String # last contact with this peer (liveness signal)
|
|
34
|
+
lastMergeAt: String # last sync where merged > 0 (data-progress signal)
|
|
34
35
|
lastSyncCursor: String # Lamport clock or ISO timestamp for incremental sync
|
|
35
36
|
relayOnly: Boolean # true for spoke-to-spoke peers announced by hub
|
|
36
37
|
pairedAt: String
|
|
@@ -43,9 +44,11 @@ type SyncLog @table(database: "flair") {
|
|
|
43
44
|
id: ID @primaryKey
|
|
44
45
|
peerId: String! @indexed # which peer this sync was with
|
|
45
46
|
direction: String! @indexed # "push" | "pull"
|
|
46
|
-
recordCount: Int # how many records in this
|
|
47
|
+
recordCount: Int # how many records merged in this batch
|
|
48
|
+
skippedCount: Int # how many records skipped (sum of skippedReasons)
|
|
49
|
+
skippedReasons: String # JSON: { unknown_table: N, non_originator: N, future_timestamp: N, no_op_same_hash: N, merge_error: N }
|
|
47
50
|
status: String @indexed # "success" | "partial" | "failed"
|
|
48
|
-
error: String #
|
|
51
|
+
error: String # human-readable summary if partial/failed (includes per-record error messages, capped)
|
|
49
52
|
durationMs: Int # how long the sync took
|
|
50
53
|
createdAt: String! @indexed
|
|
51
54
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Flair REM nightly runner shim.
|
|
3
|
+
# Deployed by `flair rem nightly enable` to {{SHIM_PATH}}.
|
|
4
|
+
# Invoked by launchd (macOS) or systemd (Linux) on the configured schedule.
|
|
5
|
+
#
|
|
6
|
+
# Single line that invokes the CLI's run-once subcommand — the runner module
|
|
7
|
+
# does all the work. Logs land in {{HOME}}/.flair/logs/.
|
|
8
|
+
set -e
|
|
9
|
+
exec {{FLAIR_BIN}} rem nightly run-once
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
3
|
+
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
4
|
+
<plist version="1.0">
|
|
5
|
+
<dict>
|
|
6
|
+
<key>Label</key>
|
|
7
|
+
<string>dev.flair.rem.nightly</string>
|
|
8
|
+
|
|
9
|
+
<key>ProgramArguments</key>
|
|
10
|
+
<array>
|
|
11
|
+
<string>{{SHIM_PATH}}</string>
|
|
12
|
+
</array>
|
|
13
|
+
|
|
14
|
+
<key>StartCalendarInterval</key>
|
|
15
|
+
<dict>
|
|
16
|
+
<key>Hour</key>
|
|
17
|
+
<integer>{{HOUR}}</integer>
|
|
18
|
+
<key>Minute</key>
|
|
19
|
+
<integer>{{MINUTE}}</integer>
|
|
20
|
+
</dict>
|
|
21
|
+
|
|
22
|
+
<key>RunAtLoad</key>
|
|
23
|
+
<false/>
|
|
24
|
+
|
|
25
|
+
<key>StandardOutPath</key>
|
|
26
|
+
<string>{{HOME}}/.flair/logs/rem-nightly.stdout.log</string>
|
|
27
|
+
|
|
28
|
+
<key>StandardErrorPath</key>
|
|
29
|
+
<string>{{HOME}}/.flair/logs/rem-nightly.stderr.log</string>
|
|
30
|
+
|
|
31
|
+
<key>WorkingDirectory</key>
|
|
32
|
+
<string>{{HOME}}</string>
|
|
33
|
+
|
|
34
|
+
<key>EnvironmentVariables</key>
|
|
35
|
+
<dict>
|
|
36
|
+
<key>HOME</key>
|
|
37
|
+
<string>{{HOME}}</string>
|
|
38
|
+
<key>PATH</key>
|
|
39
|
+
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
40
|
+
<key>FLAIR_AGENT_ID</key>
|
|
41
|
+
<string>{{AGENT_ID}}</string>
|
|
42
|
+
<key>FLAIR_URL</key>
|
|
43
|
+
<string>{{FLAIR_URL}}</string>
|
|
44
|
+
</dict>
|
|
45
|
+
</dict>
|
|
46
|
+
</plist>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Flair REM nightly cycle ({{AGENT_ID}})
|
|
3
|
+
After=network.target
|
|
4
|
+
|
|
5
|
+
[Service]
|
|
6
|
+
Type=oneshot
|
|
7
|
+
ExecStart={{SHIM_PATH}}
|
|
8
|
+
Environment=FLAIR_AGENT_ID={{AGENT_ID}}
|
|
9
|
+
Environment=FLAIR_URL={{FLAIR_URL}}
|
|
10
|
+
StandardOutput=append:{{HOME}}/.flair/logs/rem-nightly.stdout.log
|
|
11
|
+
StandardError=append:{{HOME}}/.flair/logs/rem-nightly.stderr.log
|
|
12
|
+
|
|
13
|
+
[Install]
|
|
14
|
+
WantedBy=default.target
|